From 7fc24008cdf5d48654ce6a93dd27ef0f90938ba7 Mon Sep 17 00:00:00 2001 From: Timi Date: Thu, 27 Aug 2026 16:08:00 +0800 Subject: [PATCH] init project --- .gitignore | 2 + .idea/.gitignore | 10 + .idea/modules.xml | 8 + .idea/timi-web-api.iml | 8 + .idea/vcs.xml | 6 + .serena/.gitignore | 2 + .serena/project.yml | 153 +++++++ README.md | 32 +- package.json | 43 ++ pnpm-lock.yaml | 815 +++++++++++++++++++++++++++++++++ src/api/ArticleAPI.ts | 25 + src/api/AttachmentAPI.ts | 59 +++ src/api/BaseAPI.ts | 24 + src/api/CommentAPI.ts | 15 + src/api/CommonAPI.ts | 15 + src/api/DeveloperAPI.ts | 12 + src/api/NotifyAPI.ts | 83 ++++ src/api/PermissionAPI.ts | 29 ++ src/api/RoleAPI.ts | 100 ++++ src/api/SettingAPI.ts | 26 ++ src/api/UserAPI.ts | 94 ++++ src/api/index.ts | 10 + src/editor/article.ts | 150 ++++++ src/editor/index.ts | 7 + src/editor/notify.ts | 10 + src/index.ts | 6 + src/store/index.ts | 1 + src/store/userStore.ts | 217 +++++++++ src/types/Article.ts | 52 +++ src/types/Attachment.ts | 48 ++ src/types/Comment.ts | 34 ++ src/types/Developer.ts | 6 + src/types/Model.ts | 67 +++ src/types/Notify.ts | 98 ++++ src/types/Permission.ts | 24 + src/types/Role.ts | 41 ++ src/types/Setting.ts | 27 ++ src/types/User.ts | 98 ++++ src/types/index.ts | 10 + src/utils/Network.ts | 118 +++++ src/utils/User.ts | 22 + src/utils/directives/Access.ts | 227 +++++++++ src/utils/index.ts | 5 + tsconfig.json | 37 ++ 44 files changed, 2875 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/timi-web-api.iml create mode 100644 .idea/vcs.xml create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/api/ArticleAPI.ts create mode 100644 src/api/AttachmentAPI.ts create mode 100644 src/api/BaseAPI.ts create mode 100644 src/api/CommentAPI.ts create mode 100644 src/api/CommonAPI.ts create mode 100644 src/api/DeveloperAPI.ts create mode 100644 src/api/NotifyAPI.ts create mode 100644 src/api/PermissionAPI.ts create mode 100644 src/api/RoleAPI.ts create mode 100644 src/api/SettingAPI.ts create mode 100644 src/api/UserAPI.ts create mode 100644 src/api/index.ts create mode 100644 src/editor/article.ts create mode 100644 src/editor/index.ts create mode 100644 src/editor/notify.ts create mode 100644 src/index.ts create mode 100644 src/store/index.ts create mode 100644 src/store/userStore.ts create mode 100644 src/types/Article.ts create mode 100644 src/types/Attachment.ts create mode 100644 src/types/Comment.ts create mode 100644 src/types/Developer.ts create mode 100644 src/types/Model.ts create mode 100644 src/types/Notify.ts create mode 100644 src/types/Permission.ts create mode 100644 src/types/Role.ts create mode 100644 src/types/Setting.ts create mode 100644 src/types/User.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/Network.ts create mode 100644 src/utils/User.ts create mode 100644 src/utils/directives/Access.ts create mode 100644 src/utils/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 52d105e..48c87f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/.npmrc + # ---> JetBrains # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5d16ba9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/timi-web-api.iml b/.idea/timi-web-api.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/timi-web-api.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..b095939 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,153 @@ +# the name by which the project can be referenced within Serena +project_name: "timi-web-api" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# haxe java julia kotlin lua +# markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: [] + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project based on the project name or path. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, +# for example by saying that the information retrieved from a memory file is no longer correct +# or no longer relevant for the project. +# * `edit_memory`: Replaces content matching a regular expression in a memory. +# * `execute_shell_command`: Executes a shell command. +# * `find_file`: Finds files in the given relative paths +# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend +# * `find_symbol`: Performs a global (or local) search using the language server backend. +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') +# for clients that do not read the initial instructions when the MCP server is connected. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Read the content of a memory file. This tool should only be used if the information +# is relevant to the current task. You can infer whether the information +# is relevant from the memory file name. +# You should not read the same memory file multiple times in the same conversation. +# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported +# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). +# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. +# For JB, we use a separate tool. +# * `replace_content`: Replaces content in a file (optionally using regular expressions). +# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. +# * `safe_delete_symbol`: +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. +# The memory name should be meaningful. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/README.md b/README.md index 2b1659e..f88eb10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ -# timi-web-lib +# timi-web-api + +Timi Web 的业务 API、领域类型、用户状态和权限相关能力。 + +通用组件与工具位于 [timi-web](https://gitea.imyeyu.com/timi/timi-web),本项目使用自己的 Axios 网络层,并依赖它提供 Cookie、Storage 等通用工具。 + +## 依赖方向 + +本项目是独立的业务能力包。只有需要业务 API、领域类型、用户状态或权限能力的项目才安装 `timi-web-api`;只使用通用组件的项目不需要安装本项目。 + +```text +只使用通用组件的业务项目 -> timi-tdesign-mobile / timi-tdesign-pc -> timi-web +需要业务能力的业务项目 -> timi-web-api -> timi-web +``` + +`timi-tdesign-mobile` 和 `timi-tdesign-pc` 不依赖 `timi-web-api`。其中的文章、通知组件只约束 `Article`、`Attachment`、`NotifyDetail` 的最小字段,业务对象可以携带自定义字段,并通过上传和资源解析回调接入任意后端。 + +## 目录 + +- `src/api`:业务接口封装 +- `src/types`:业务领域类型 +- `src/store`:用户登录状态 +- `src/utils`:权限指令和配置映射 +- `src/editor`:文章、通知编辑器的数据适配 + +## 检查 + +```bash +pnpm install +pnpm run check +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..08a680e --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "timi-web-api", + "version": "0.0.1", + "description": "Timi Web 业务 API、领域类型和用户状态库", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.js", + "private": false, + "license": "MIT", + "files": [ + "dist/**", + "src/**", + "README.md", + "package.json" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js", + "require": "./dist/editor/index.js" + } + }, + "scripts": { + "build": "tsc --outDir dist --declaration --declarationMap", + "check": "tsc --noEmit" + }, + "peerDependencies": { + "timi-web": "^0.0.24", + "vue": "^3.5.26" + }, + "dependencies": { + "axios": "^1.18.0", + "pinia": "^3.0.2" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d25a893 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,815 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.18.0 + version: 1.18.0 + pinia: + specifier: ^3.0.2 + version: 3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + timi-web: + specifier: ^0.0.24 + version: 0.0.24(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + vue: + specifier: ^3.5.26 + version: 3.5.41(typescript@5.9.3) + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.0: + resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + copy-anything@4.1.0: + resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + less@4.5.1: + resolution: {integrity: sha512-UKgI3/KON4u6ngSsnDADsUERqhZknsVZbnuzlRZXLQCmfC/MDld42fTydUE9B+Mla1AL6SJ/Pp6SlEFi/AVGfw==} + engines: {node: '>=14'} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + marked-gfm-heading-id@4.1.4: + resolution: {integrity: sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==} + peerDependencies: + marked: '>=13 <19' + + marked-highlight@2.2.4: + resolution: {integrity: sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==} + peerDependencies: + marked: '>=4 <19' + + marked-mangle@1.1.13: + resolution: {integrity: sha512-phz1W/nYMr1T08Q7wqH2aj+PPiK85E69WQGfId+prvryfgjY/Idibx4YUvKaYMDV9rK1qo+/yC+Quu/3gdaBeA==} + peerDependencies: + marked: '>=4 <19' + + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} + engines: {node: '>= 4.4.x'} + hasBin: true + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + terser@5.50.0: + resolution: {integrity: sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==} + engines: {node: '>=10'} + hasBin: true + + timi-web@0.0.24: + resolution: {integrity: sha512-qzcm72W9EE3BrYiUzc8a20J8xqxH17moEm/ovdijhRbIJtJUr81sslXFe2+OjNtx14meEIPJm3IfRooFzRswLg==} + engines: {node: '>=16.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + asynckit@0.4.0: {} + + axios@1.18.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + birpc@2.9.0: {} + + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + + copy-anything@4.1.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@7.0.1: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@2.0.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + github-slugger@2.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + image-size@0.5.5: + optional: true + + is-what@3.14.1: {} + + less@4.5.1: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.5.0 + source-map: 0.6.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + marked-gfm-heading-id@4.1.4(marked@17.0.6): + dependencies: + github-slugger: 2.0.0 + marked: 17.0.6 + + marked-highlight@2.2.4(marked@17.0.6): + dependencies: + marked: 17.0.6 + + marked-mangle@1.1.13(marked@17.0.6): + dependencies: + marked: 17.0.6 + + marked@17.0.6: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: + optional: true + + mitt@3.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + needle@3.5.0: + dependencies: + iconv-lite: 0.6.3 + sax: 1.6.1 + optional: true + + parse-node-version@1.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + pify@4.0.1: + optional: true + + pinia@3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.41(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prismjs@1.30.0: {} + + proxy-from-env@2.1.0: {} + + prr@1.0.1: + optional: true + + rfdc@1.4.1: {} + + safer-buffer@2.1.2: + optional: true + + sax@1.6.1: + optional: true + + semver@5.7.2: + optional: true + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + speakingurl@14.0.1: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.1.0 + + terser@5.50.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + timi-web@0.0.24(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)): + dependencies: + axios: 1.18.0 + less: 4.5.1 + marked: 17.0.6 + marked-gfm-heading-id: 4.1.4(marked@17.0.6) + marked-highlight: 2.2.4(marked@17.0.6) + marked-mangle: 1.1.13(marked@17.0.6) + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + prismjs: 1.30.0 + terser: 5.50.0 + transitivePeerDependencies: + - debug + - supports-color + - typescript + - vue + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + vue@3.5.41(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 5.9.3 diff --git a/src/api/ArticleAPI.ts b/src/api/ArticleAPI.ts new file mode 100644 index 0000000..7f2e973 --- /dev/null +++ b/src/api/ArticleAPI.ts @@ -0,0 +1,25 @@ +import type { Article } from "../types"; +import { axios } from "./BaseAPI"; + +export const ArticleAPI = { + /** + * 获取文章 + * + * @param id 文章 ID + * @returns 文章数据 + */ + async view(id: string): Promise
{ + return axios.get(`/article/${id}`); + }, + /** + * 喜欢文章,后端有限调用,1240ms 一次 + * + * @param id 文章 ID + * @returns 最新喜欢数量 + */ + async like(id: string): Promise { + return axios.get(`/article/like/${id}`); + } +}; + +export default ArticleAPI; diff --git a/src/api/AttachmentAPI.ts b/src/api/AttachmentAPI.ts new file mode 100644 index 0000000..a0a6947 --- /dev/null +++ b/src/api/AttachmentAPI.ts @@ -0,0 +1,59 @@ +import type { Attachment, BizIdUpdate, TempFile } from "../types"; +import { axios, getBaseURI } from "./BaseAPI"; + +const BASE_URI = "/attach"; + +export const AttachmentAPI = { + /** + * 上传临时附件 + * + * @param file 文件或文件列表 + * @param ttl 有效期 + * @returns 临时附件列表 + */ + async upload(file: File | File[], ttl?: string): Promise { + const formData = new FormData(); + const fileList = Array.isArray(file) ? file : [file]; + for (const item of fileList) { + formData.append("file", item); + } + if (ttl) { + formData.append("ttl", ttl); + } + return await axios.post(`${BASE_URI}/temp/upload`, formData); + }, + /** + * 按业务差分更新附件 + * + * @param req 更新请求 + */ + async updateByBizId(req: BizIdUpdate): Promise { + return await axios.post(`${BASE_URI}/update/biz/id`, req); + }, + /** + * 按业务查询附件列表 + * + * @param bizType 业务类型 + * @param bizId 业务 ID + * @param attachTypeList 附件类型列表 + */ + async listByBizId(bizType: string, bizId: string, attachTypeList?: string[]): Promise { + return await axios.get(`${BASE_URI}/list/biz/id`, { + params: { + bizType, + bizId, + attachTypeList + } + }); + }, + + getReadURL(id: string): string { + return `${getBaseURI()}${BASE_URI}/read/${id}`; + }, + + getTempReadURL(id: string): string { + return `${getBaseURI()}${BASE_URI}/temp/read?id=${id}`; + } +}; + +export default AttachmentAPI; diff --git a/src/api/BaseAPI.ts b/src/api/BaseAPI.ts new file mode 100644 index 0000000..d7a06f1 --- /dev/null +++ b/src/api/BaseAPI.ts @@ -0,0 +1,24 @@ +import Network from "../utils/Network"; + +let serviceAxios: any | null = null; + +function getServiceAxios(): any { + if (serviceAxios === null) { + serviceAxios = Network.axios; + } + return serviceAxios; +} + +export const axios = new Proxy({} as any, { + get(_target: any, prop: string | symbol, receiver: any): any { + return Reflect.get(getServiceAxios(), prop, receiver); + } +}); + +export function getBaseURI(): string { + const baseURL = axios.defaults.baseURL; + if (!baseURL || baseURL === "undefined") { + return ""; + } + return baseURL.replace(/\/$/, ""); +} diff --git a/src/api/CommentAPI.ts b/src/api/CommentAPI.ts new file mode 100644 index 0000000..8e7d84b --- /dev/null +++ b/src/api/CommentAPI.ts @@ -0,0 +1,15 @@ +import type { CaptchaData, Comment, Page, PageResult } from "../types"; +import { axios } from "./BaseAPI"; + +const BASE_URI = "/comment"; + +export const CommentAPI = { + async page(page: Page): Promise> { + return axios.post(`${BASE_URI}/list`, page); + }, + async create(captchaData: CaptchaData): Promise { + return axios.post(`${BASE_URI}/create`, captchaData); + } +}; + +export default CommentAPI; diff --git a/src/api/CommonAPI.ts b/src/api/CommonAPI.ts new file mode 100644 index 0000000..2f8d944 --- /dev/null +++ b/src/api/CommonAPI.ts @@ -0,0 +1,15 @@ +import type { CaptchaResult } from "../types"; +import { axios, getBaseURI } from "./BaseAPI"; + +export const CommonAPI = { + + getCaptchaAPI(): string { + return `${getBaseURI()}/captcha`; + }, + + async captcha(width: number, height: number): Promise { + return await axios.get(`/captcha?width=${width}&height=${height}`); + }, +}; + +export default CommonAPI; diff --git a/src/api/DeveloperAPI.ts b/src/api/DeveloperAPI.ts new file mode 100644 index 0000000..8d3af76 --- /dev/null +++ b/src/api/DeveloperAPI.ts @@ -0,0 +1,12 @@ +import type { Developer } from "../types"; +import { axios } from "./BaseAPI"; + +const BASE_URI = "/git/developer"; + +export const DeveloperAPI = { + async get(): Promise { + return axios.post(`${BASE_URI}`); + } +}; + +export default DeveloperAPI; diff --git a/src/api/NotifyAPI.ts b/src/api/NotifyAPI.ts new file mode 100644 index 0000000..055274e --- /dev/null +++ b/src/api/NotifyAPI.ts @@ -0,0 +1,83 @@ +import { axios } from "./BaseAPI"; +import type { CaptchaData, Notify, NotifyDetail, NotifyManagementItem, Page, PageResult } from "../types"; + +const BASE_URI = "/notify/internal"; +const MANAGEMENT_BASE_URI = "/notify/management"; + +export const NotifyAPI = { + + /** 管理端创建并发送通知。 */ + async create(notify: Notify): Promise { + return await axios.post("/notify/create", notify); + }, + + /** 查询当前管理用户创建的通知列表。 */ + async listManagement(page?: Page): Promise> { + return await axios.post(`${MANAGEMENT_BASE_URI}/list`, page); + }, + + /** 查询管理端通知详情及收件人。 */ + async managementDetail(id: string): Promise { + return await axios.post(`${MANAGEMENT_BASE_URI}/detail`, undefined, { + params: { id } + }); + }, + + /** 删除管理端通知。 */ + async managementDelete(id: string): Promise { + await axios.post(`${MANAGEMENT_BASE_URI}/delete`, undefined, { + params: { id } + }); + }, + + /** 取消管理端尚未发送的通知。 */ + async managementCancel(id: string): Promise { + await axios.post(`${MANAGEMENT_BASE_URI}/cancel`, undefined, { + params: { id } + }); + }, + + async smsSend(captchaData: CaptchaData) { + return await axios.post("/notify/sms/captcha/create", captchaData); + }, + + /** 查询当前用户的站内通知详情。 */ + async internalDetail(id: string): Promise { + return await axios.post(`${BASE_URI}/detail`, undefined, { + params: { id } + }); + }, + + /** 查询当前用户的站内通知。 */ + async listInternal(page?: Page, unreadOnly = false): Promise> { + return await axios.post(`${BASE_URI}/internal`, page, { + params: { unreadOnly } + }); + }, + + /** 查询当前用户未读站内通知数量。 */ + async countUnreadInternal(): Promise { + return await axios.post(`${BASE_URI}/unread/count`); + }, + + /** 标记当前用户的一条站内通知已读。 */ + async markInternalRead(id: string): Promise { + await axios.post(`${BASE_URI}/read`, undefined, { + params: { id } + }); + }, + + /** 删除当前用户的一条站内通知。 */ + async deleteInternal(id: string): Promise { + await axios.post(`${BASE_URI}/delete`, undefined, { + params: { id } + }); + }, + + /** 标记当前用户全部站内通知已读。 */ + async markAllInternalRead(): Promise { + await axios.post(`${BASE_URI}/read/all`); + } +}; + +export default NotifyAPI; diff --git a/src/api/PermissionAPI.ts b/src/api/PermissionAPI.ts new file mode 100644 index 0000000..2e8a6fe --- /dev/null +++ b/src/api/PermissionAPI.ts @@ -0,0 +1,29 @@ +import { axios } from "./BaseAPI"; +import type { ModuleCode, Page, PageResult, Permission, PermissionPayload } from "../types"; + +const BASE_URI = "/user/permission"; + +export const PermissionAPI = { + async list(req: Page): Promise> { + return axios.post(`${BASE_URI}/list`, req); + }, + async create(req: PermissionPayload): Promise { + return axios.post(`${BASE_URI}/create`, req); + }, + async update(req: PermissionPayload): Promise { + return axios.post(`${BASE_URI}/update`, req); + }, + async remove(id: string): Promise { + return axios.post(`${BASE_URI}/delete`, undefined, { + params: { id } + }); + }, + /** 查询当前登录用户在指定模块内可转授的权限。 */ + async grantableList(moduleCode: ModuleCode): Promise { + return await axios.post(`${BASE_URI}/grantable/list`, undefined, { + params: { moduleCode } + }); + } +}; + +export default PermissionAPI; diff --git a/src/api/RoleAPI.ts b/src/api/RoleAPI.ts new file mode 100644 index 0000000..c5ce191 --- /dev/null +++ b/src/api/RoleAPI.ts @@ -0,0 +1,100 @@ +import { axios } from "./BaseAPI"; +import type { ModuleCode, Page, PageResult, Permission, Role, RolePayload, AuthorizeUserRole } from "../types"; + +const BASE_URI = "/user/role"; + +export const RoleAPI = { + async list(req: Page): Promise> { + return axios.post(`${BASE_URI}/list`, req); + }, + async detail(id: string): Promise { + return axios.post(`${BASE_URI}/detail`, undefined, { + params: { id } + }); + }, + async create(req: RolePayload): Promise { + return axios.post(`${BASE_URI}/create`, req); + }, + async update(req: RolePayload): Promise { + return axios.post(`${BASE_URI}/update`, req); + }, + async remove(id: string): Promise { + return axios.post(`${BASE_URI}/delete`, undefined, { + params: { id } + }); + }, + /** 查询当前登录用户在模块内可管理的角色。 */ + async manageableList(moduleCode: ModuleCode): Promise { + return await axios.post(`${BASE_URI}/manageable/list`, undefined, { + params: { moduleCode } + }); + }, + /** 查询当前登录用户在模块内可授权给账号的角色。 */ + async grantableList(moduleCode: ModuleCode): Promise { + return await axios.post(`${BASE_URI}/grantable/list`, undefined, { + params: { moduleCode } + }); + }, + /** 查询当前登录用户直属角色详情,不按可管理范围过滤。 */ + async currentDetailList(moduleCode?: ModuleCode): Promise { + return await axios.post(`${BASE_URI}/current/detail/list`, undefined, { + params: { moduleCode } + }); + }, + /** 查询当前登录用户可以给目标角色配置的直接权限 P 候选项。 */ + async grantablePermissionList(roleId: string): Promise { + return await axios.post(`${BASE_URI}/permission/grantable/list`, undefined, { + params: { roleId } + }); + }, + /** 查询目标角色当前可继续转授的权限 D。 */ + async delegationList(roleId: string): Promise { + return await axios.post(`${BASE_URI}/delegation/list`, undefined, { + params: { roleId } + }); + }, + /** 查询当前登录用户可以给目标角色配置的 D 候选项。 */ + async grantableDelegationList(roleId: string): Promise { + return await axios.post(`${BASE_URI}/delegation/grantable/list`, undefined, { + params: { roleId } + }); + }, + /** 覆盖保存目标角色可继续转授的权限 D。 */ + async updateDelegation(req: Pick): Promise { + await axios.post(`${BASE_URI}/delegation/update`, req); + }, + /** 查询目标角色可选管理父级。 */ + async parentGrantableList(roleId: string): Promise { + return await axios.post(`${BASE_URI}/parent/grantable/list`, undefined, { + params: { roleId } + }); + }, + /** 查询创建模块角色时可选的管理父级。 */ + async createParentGrantableList(moduleCode: ModuleCode): Promise { + return await axios.post(`${BASE_URI}/parent/create/grantable/list`, undefined, { + params: { moduleCode } + }); + }, + /** 保存目标角色管理父级。 */ + async updateParent(req: Pick): Promise { + await axios.post(`${BASE_URI}/parent/update`, req); + }, + async authorizedList(userId: string, moduleCode?: ModuleCode): Promise { + return axios.post(`${BASE_URI}/authorized/list`, undefined, { + params: { + userId, + moduleCode + } + }); + }, + async authorize(req: AuthorizeUserRole): Promise { + return axios.post(`${BASE_URI}/authorized/create`, req); + }, + async authorizedPermission(userId: string): Promise { + return axios.post(`${BASE_URI}/authorized/permission`, undefined, { + params: { userId } + }); + } +}; + +export default RoleAPI; diff --git a/src/api/SettingAPI.ts b/src/api/SettingAPI.ts new file mode 100644 index 0000000..60c3ce4 --- /dev/null +++ b/src/api/SettingAPI.ts @@ -0,0 +1,26 @@ +import type { Setting } from "../types"; +import { axios } from "./BaseAPI"; + +const BASE_URI = "/setting"; + +export const SettingAPI = { + + /** + * 查询公开配置映射 + * + * @param bizType 业务类型 + * @param bizId 业务 ID,可为空 + * @param code 配置代码列表,可为空 + */ + async map(bizType: string, bizId?: string, code?: string[]): Promise> { + return await axios.get(`${BASE_URI}/map`, { + params: { + bizType, + bizId, + code + } + }); + } +}; + +export default SettingAPI; diff --git a/src/api/UserAPI.ts b/src/api/UserAPI.ts new file mode 100644 index 0000000..1a69fbd --- /dev/null +++ b/src/api/UserAPI.ts @@ -0,0 +1,94 @@ +import type { + Attachment, + CaptchaData, + Gender, + LoginRequest, + LoginResponse, + RegisterRequest, + SmsLoginRequest, + UpdatePasswordRequest, + User, + UserAttachType +} from "../types"; +import { axios } from "./BaseAPI"; +import AttachmentAPI from "./AttachmentAPI"; + +const BASE_URI = "/user"; +type UserAttachmentOwner = { + attachmentList?: readonly Attachment[]; +}; +type UpdateCurrentRequest = Omit, "gender"> & { + gender?: Gender | ""; +}; + +function findAttachmentByType(attachmentList: readonly Attachment[], ...types: UserAttachType[]) { + for (let i = 0; i < attachmentList.length; i++) { + const attachType = attachmentList[i].attachType as UserAttachType | undefined; + const id = attachmentList[i].id; + if (id && attachType && types.includes(attachType)) { + return AttachmentAPI.getReadURL(id); + } + } +} + +export const UserAPI = { + async register(req: CaptchaData): Promise { + return await axios.post(`${BASE_URI}/register`, req); + }, + /** + * 登录 + * + * @param req 验证码登录对象 + * @returns LoginResponse + */ + async login(req: CaptchaData): Promise { + return await axios.post(`${BASE_URI}/login`, req); + }, + /** + * 短信验证码登录 + * + * @param req 短信验证码登录请求 + * @returns LoginResponse + */ + async loginBySms(req: SmsLoginRequest): Promise { + return await axios.post(`${BASE_URI}/login/sms`, req); + }, + /** + * 验证是否已登录 + * + * @returns true 为已登录 + */ + async loginByToken(): Promise { + return await axios.post(`${BASE_URI}/login/token`); + }, + async logout(): Promise { + return await axios.post(`${BASE_URI}/logout`); + }, + async updateCurrent(req: UpdateCurrentRequest): Promise { + return await axios.post(`${BASE_URI}/current/update`, req); + }, + async updatePassword(req: UpdatePasswordRequest): Promise { + return await axios.post(`${BASE_URI}/update/password`, req); + }, + /** + * 获取用户数据 + * + * @param id 用户 ID + * @returns 用户数据 + */ + async view(id: string): Promise { + return await axios.post(`${BASE_URI}/view/${id}`); + }, + getAvatarURL(user?: UserAttachmentOwner) { + if (user?.attachmentList) { + return findAttachmentByType(user.attachmentList, "AVATAR"); + } + }, + getWrapperURL(user?: UserAttachmentOwner) { + if (user?.attachmentList) { + return findAttachmentByType(user.attachmentList, "WRAPPER"); + } + } +}; + +export default UserAPI; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..be6018f --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,10 @@ +export { ArticleAPI } from "./ArticleAPI"; +export { AttachmentAPI } from "./AttachmentAPI"; +export { CommentAPI } from "./CommentAPI"; +export { CommonAPI } from "./CommonAPI"; +export { DeveloperAPI } from "./DeveloperAPI"; +export { NotifyAPI } from "./NotifyAPI"; +export { PermissionAPI } from "./PermissionAPI"; +export { RoleAPI } from "./RoleAPI"; +export { SettingAPI } from "./SettingAPI"; +export { UserAPI } from "./UserAPI"; diff --git a/src/editor/article.ts b/src/editor/article.ts new file mode 100644 index 0000000..a7d122f --- /dev/null +++ b/src/editor/article.ts @@ -0,0 +1,150 @@ +/** + * 文章编辑器的公共数据处理。 + * 负责统一内容类型、附件标识和存储前的富文本格式,平台层不参与这些规则。 + */ +import type { ArticleContentType, Attachment } from "../types"; +import { ATTACHMENT_ID_ATTR, isLocalMediaId, normalizeContent, TEMP_FILE_ID_ATTR } from "timi-web/editor"; + +/** 文章内容中附件和临时文件的标识前缀。 */ +export const ATTACHMENT_ID_PREFIX = "attachment-id:"; +export const TEMP_FILE_ID_PREFIX = "temp-file-id:"; +export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE"; +export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO"; +/** 支持的文章内容类型和默认类型。 */ +export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP"; +export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"]; + +/** 将后端历史值和未知值归一化为当前支持的内容类型。 */ +export function normalizeArticleContentType(contentType?: string): ArticleContentType { + return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE; +} + +/** 将空内容转换为空字符串,避免编辑器收到 undefined。 */ +export function normalizeRawArticleContent(content?: string) { + return content || ""; +} + +/** 从媒体 src 中读取持久化附件 ID。 */ +export function getAttachmentIdFromSrc(src?: string) { + if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) { + return ""; + } + return src.slice(ATTACHMENT_ID_PREFIX.length); +} + +/** 从媒体 src 中读取临时文件 ID。 */ +export function getTempFileIdFromSrc(src?: string) { + if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) { + return ""; + } + return src.slice(TEMP_FILE_ID_PREFIX.length); +} + +/** 构造带有附件 ID 的内部媒体地址。 */ +export function buildAttachmentIdSrc(attachmentId: string) { + return `${ATTACHMENT_ID_PREFIX}${attachmentId}`; +} + +/** 根据媒体标签判断附件类型。 */ +function getMediaAttachType(tagName: string) { + return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE; +} + +/** 仅为 blob 预览地址保留预览元数据,服务器地址不重复保存。 */ +function buildAttachmentMetadata(src: string) { + if (!src.startsWith("blob:")) { + return undefined; + } + return { + previewUrl: src + }; +} + +/** 过滤本地临时媒体 ID,避免把前端占位 ID 当成可上传附件。 */ +function normalizeAttachmentTempFileId(tempFileId?: string) { + if (!tempFileId || isLocalMediaId(tempFileId)) { + return undefined; + } + return tempFileId; +} + +/** 从文章 HTML 中提取图片和视频附件,并按 ID 去重。 */ +export function buildAttachmentListFromContent(content?: string) { + const rawContent = normalizeContent(content); + if (!rawContent) { + return [] as Attachment[]; + } + const parser = new DOMParser(); + const doc = parser.parseFromString(rawContent, "text/html"); + const mediaList = Array.from(doc.querySelectorAll("img, video")); + const attachmentMap = new Map(); + + for (const media of mediaList) { + const src = media.getAttribute("src") || ""; + const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src); + const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src); + const key = attachmentId || tempFileId; + if (!key || attachmentMap.has(key)) { + continue; + } + attachmentMap.set(key, { + id: attachmentId || key, + tempFileId: normalizeAttachmentTempFileId(tempFileId), + name: media.getAttribute("title") || media.getAttribute("alt") || "", + attachType: getMediaAttachType(media.tagName), + metadata: buildAttachmentMetadata(src) + }); + } + + return Array.from(attachmentMap.values()); +} + +/** 将媒体地址统一改写为附件地址,供后端保存文章内容。 */ +export function normalizeContentForStore(content?: string) { + const rawContent = normalizeContent(content); + if (!rawContent) { + return ""; + } + const parser = new DOMParser(); + const doc = parser.parseFromString(rawContent, "text/html"); + const mediaList = Array.from(doc.querySelectorAll("img, video")); + for (const media of mediaList) { + const src = media.getAttribute("src") || ""; + const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src); + const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src); + const finalAttachmentId = attachmentId || tempFileId; + if (!finalAttachmentId) { + continue; + } + media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId); + media.removeAttribute(TEMP_FILE_ID_ATTR); + media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId)); + } + return normalizeContent(doc.body.innerHTML); +} + +/** 比较附件列表的业务字段,不比较对象引用。 */ +export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) { + const leftList = left || []; + const rightList = right || []; + if (leftList.length !== rightList.length) { + return false; + } + for (let i = 0; i < leftList.length; i += 1) { + const leftItem = leftList[i]; + const rightItem = rightList[i]; + if (!rightItem) { + return false; + } + if (leftItem.id !== rightItem.id) { + return false; + } + if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) { + return false; + } + if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) { + return false; + } + } + return true; +} diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..352518b --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1,7 @@ +/** 业务编辑器数据适配和通用编辑器能力出口。 */ +export * from "timi-web/editor"; +export * from "./article"; +export * from "./notify"; +export type { Article, ArticleContentType } from "../types/Article"; +export type { Attachment } from "../types/Attachment"; +export type { NotifyDetail } from "../types/Notify"; diff --git a/src/editor/notify.ts b/src/editor/notify.ts new file mode 100644 index 0000000..52f7b89 --- /dev/null +++ b/src/editor/notify.ts @@ -0,0 +1,10 @@ +/** 通知编辑器的公共数据规则。 */ +import type { NotifyContentType } from "../types"; + +/** 通知缺少内容类型时默认使用 Tiptap。 */ +export const DEFAULT_NOTIFY_CONTENT_TYPE: NotifyContentType = "TIP_TAP"; + +/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况。 */ +export function normalizeNotifyContentType(contentType?: string): NotifyContentType { + return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..69104de --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +export * from "./api"; +export { axios } from "./api/BaseAPI"; +export * from "./editor"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..32628ba --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from "./userStore"; diff --git a/src/store/userStore.ts b/src/store/userStore.ts new file mode 100644 index 0000000..966d6e1 --- /dev/null +++ b/src/store/userStore.ts @@ -0,0 +1,217 @@ +import {defineStore} from "pinia"; +import {computed, reactive, readonly, ref} from "vue"; +import type {CaptchaData, LoginRequest, LoginResponse, LoginUser} from "../types"; +import {Cooker, Storage} from "timi-web"; +import {UserAPI} from "../api"; + +/** 用户 Store 配置项 */ +interface UserStoreOptions { + /** localStorage 储存 key,默认 "loginUser" */ + storageKey?: string; +} + +/** 访问控制匹配模式 */ +export type AccessMatchMode = "and" | "or"; + +let _storageKey = "loginUser"; + +type AccessItem = string | { + moduleCode?: string; + code?: string; +}; + +function normalizeAccessItem(item?: AccessItem): string { + if (!item) { + return ""; + } + + if (typeof item === "string") { + return item.trim(); + } + + const code = item.code?.trim(); + if (!code) { + return ""; + } + + const moduleCode = item.moduleCode?.trim(); + return moduleCode && !code.includes(":") ? `${moduleCode}:${code}` : code; +} + +function normalizeAccessList(list?: AccessItem[]): string[] { + if (!list?.length) { + return []; + } + return list + .map(normalizeAccessItem) + .filter((item): item is string => !!item); +} + +function matchAccess(sourceList?: string[], targetList?: string | string[], mode: AccessMatchMode = "or"): boolean { + const normalizedTargetList = normalizeAccessList(Array.isArray(targetList) ? targetList : [targetList || ""]); + if (!normalizedTargetList.length) { + return true; + } + + const sourceSet = new Set(normalizeAccessList(sourceList)); + if (!sourceSet.size) { + return false; + } + + if (mode === "and") { + return normalizedTargetList.every(item => sourceSet.has(item)); + } + return normalizedTargetList.some(item => sourceSet.has(item)); +} + +/** + * 配置用户 Store,应在使用 useUserStore 之前调用 + * @param options 配置项 + */ +function configureUserStore(options: UserStoreOptions): void { + if (options.storageKey) { + _storageKey = options.storageKey; + } +} + +const useUserStore = defineStore("user", () => { + type LoginUserState = LoginUser & Record; + + const loginUser = reactive({}); + const isRestored = ref(false); + + const token = computed(() => loginUser.token ?? ""); + const isLoggedIn = computed(() => isValidLoginUser(loginUser)); + + function resetLoginUser(nextUser?: LoginUserState): void { + Object.keys(loginUser).forEach((key) => { + delete loginUser[key]; + }); + if (nextUser) { + Object.assign(loginUser, nextUser); + } + } + + function isValidLoginUser(user?: LoginUserState | null): user is LoginResponse { + return !!user?.token && !!user.expireAt && new Date().getTime() < user.expireAt; + } + + function isLogged(): boolean { + return isLoggedIn.value; + } + + function getLoginUser(): T | null { + if (!isLogged()) { + return null; + } + return loginUser as T; + } + + function getRoleList(): string[] { + return normalizeAccessList(loginUser.user?.roleList); + } + + function getPermissionList(): string[] { + return normalizeAccessList(loginUser.user?.permissionList); + } + + function hasRoles(roleList: string | string[], mode: AccessMatchMode = "or"): boolean { + return matchAccess(getRoleList(), roleList, mode); + } + + function hasPermissions(permissionList: string | string[], mode: AccessMatchMode = "or"): boolean { + return matchAccess(getPermissionList(), permissionList, mode); + } + + async function login(req: CaptchaData): Promise { + const result = await UserAPI.login(req); + const nextUser = result as unknown as T; + await updateLoginUser(nextUser); + return nextUser; + } + + async function loginByToken(token: string): Promise { + loginUser.token = token; + // 未过期 + try { + const result = await UserAPI.loginByToken(); + if (!result) { + return null; + } + const nextUser = result as unknown as T; + await updateLoginUser(nextUser); + return nextUser; + } catch (e) { + console.error(e); + clearLoginUser(); + } + return null; + } + + async function loginByStorage(): Promise { + if (loginUser.token) { + isRestored.value = true; + return getLoginUser(); + } + + if (!Storage.has(_storageKey)) { + isRestored.value = true; + return null; + } + + // 未登录,储存有令牌。 + const storageUser = Storage.getJSON(_storageKey) as T; + if (isValidLoginUser(storageUser)) { + const result = await loginByToken(storageUser.token); + isRestored.value = true; + return result; + } + + clearLoginUser(); + isRestored.value = true; + return null; + } + + async function updateLoginUser(user: T): Promise { + resetLoginUser(user as LoginUserState); + Storage.setJSON(_storageKey, user); + } + + function clearLoginUser(): void { + resetLoginUser(); + Cooker.remove("Token"); + Storage.remove(_storageKey); + } + + async function logout(): Promise { + try { + await UserAPI.logout(); + } finally { + clearLoginUser(); + } + } + + return { + token, + isLoggedIn, + isRestored, + getRoleList, + getPermissionList, + hasRoles, + hasPermissions, + isLogged, + loginUser: readonly(loginUser), + getLoginUser, + login, + loginByToken, + loginByStorage, + updateLoginUser, + clearLoginUser, + logout + }; +}); + +export { + useUserStore, + configureUserStore +}; diff --git a/src/types/Article.ts b/src/types/Article.ts new file mode 100644 index 0000000..9f3e76d --- /dev/null +++ b/src/types/Article.ts @@ -0,0 +1,52 @@ +import type { Attachment } from "./Attachment"; +import type { Comment } from "./Comment"; +import type { Model } from "./Model"; +import type { Setting } from "./Setting"; + +/** 文章业务类型 */ +export type ArticleBizType = "BLOG" | "FEEDBACK"; + +/** 文章正文类型 */ +export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP"; + +/** 文章状态 */ +export type ArticleStatus = "DART" | "NORMAL" | "HIDDEN"; + +/** 文章实体 */ +export type Article = { + /** 业务类型 */ + bizType?: ArticleBizType; + + /** 业务 ID */ + bizId?: string; + + /** 标题 */ + title?: string; + + /** 描述 */ + description?: string; + + /** 正文类型 */ + contentType?: ArticleContentType; + + /** 正文内容 */ + content?: string; + + /** 阅读数量 */ + reads?: number; + + /** 喜欢数量 */ + likes?: number; + + /** 文章状态 */ + status?: ArticleStatus; + + /** 设置列表 */ + settingList?: Setting[]; + + /** 评论列表 */ + commentList?: Comment[]; + + /** 附件列表 */ + attachmentList?: Attachment[]; +} & Model; diff --git a/src/types/Attachment.ts b/src/types/Attachment.ts new file mode 100644 index 0000000..f888e4c --- /dev/null +++ b/src/types/Attachment.ts @@ -0,0 +1,48 @@ +import type { Model } from "./Model"; + +export type Attachment = { + bizType?: AttachmentBizType; + bizId?: string; + attachType?: string; + mongoId?: string; + title?: string; + name?: string; + mimeType?: string; + metadata?: Record; + accessKey?: AttachmentAccessKey; + size?: number; + md5?: string; + uploaderIp?: string; + isDestroyed?: boolean; + destroyAt?: number; + tempFileId?: string; +} & Model; + +export type TempFile = { + id: string; + expireAt?: number; +}; + +export type BizIdUpdate = { + bizType: AttachmentBizType; + bizId: string; + items: Partial[]; +}; + +export type AttachmentBizType = + | "ARTICLE" + | "USER" + | "GAO_CUSTOMER" + | "GAO_EVENT_RECORD" + | "GAO_REGISTER_RECORD" + | "TEMP_FILE" + | "MIRROR"; + +/** 附件访问密钥 */ +export type AttachmentAccessKey = { + value: string; + attachmentId: string; + isDisposable: boolean; + expiredAt: number; + createdAt: number; +}; diff --git a/src/types/Comment.ts b/src/types/Comment.ts new file mode 100644 index 0000000..8b8903a --- /dev/null +++ b/src/types/Comment.ts @@ -0,0 +1,34 @@ +import type { Model, Page } from "./Model"; +import type { User } from "./User"; + +/** 评论 */ +export type Comment = { + bizType?: CommentBizType; + bizId?: number; + userId?: number; + nick?: string; + content?: string; + + /** 所属用户 */ + user?: User; + + /** 回复列表 */ + replies?: Comment[]; + + /** 回复分页 */ + repliesPage?: Page; + + /** 用于绑定组件当前页下标 */ + repliesCurrent?: number; + + /** 回复数量 */ + repliesLength?: number; + + /** 关联文章 */ + article?: object; + + /** 关联 Git 仓库 */ + repository?: object; +} & Model + +export type CommentBizType = "ARTICLE" | "GIT_ISSUE" | "GIT_MERGE"; diff --git a/src/types/Developer.ts b/src/types/Developer.ts new file mode 100644 index 0000000..859c0c3 --- /dev/null +++ b/src/types/Developer.ts @@ -0,0 +1,6 @@ +/** 开发者配置 */ +export type Developer = { + developerId?: number; + name?: string; + rsa?: string; +} diff --git a/src/types/Model.ts b/src/types/Model.ts new file mode 100644 index 0000000..35beb6d --- /dev/null +++ b/src/types/Model.ts @@ -0,0 +1,67 @@ +// 基本实体模型 +export type Model = { + id?: string; + + createdAt?: number; + updatedAt?: number; + deletedAt?: number; +} + +export type ApiResponse = { + code: number; + message: string; + messageCode: string; + success: boolean; + fail: boolean; + data: T; +} + +export type Page = { + orderMap?: { [K in keyof T]?: OrderType }; + equalsExample?: Partial; + likesExample?: Partial; +} & BasePage; + +export type BasePage = { + index: number; + size: number; +} + +export type OrderType = "ASC" | "DESC"; + +export type PageResult = { + total: number; + list: T[]; +} + +// 携带验证码的请求体 +export type CaptchaData = { + captchaId: string; + captcha: string; + data: T; +} + +// 图形验证码 +export type CaptchaResult = { + id: string; + data: string; +} + +/** 用户背景图和头像的图片渲染样式。 */ +export const ImageType = { + "IR-AUTO": "ir-auto", + "IR-SMOOTH": "ir-smooth", + "IR-PIXELATED": "ir-pixelated" +} as const; + +export type ImageType = keyof typeof ImageType; + +export type KeyValue = { + key: K; + value?: V; +} + +export type LabelValue = { + label: L; + value?: V; +} diff --git a/src/types/Notify.ts b/src/types/Notify.ts new file mode 100644 index 0000000..aedeb71 --- /dev/null +++ b/src/types/Notify.ts @@ -0,0 +1,98 @@ +import type { Model } from "./Model"; +import type { Attachment } from "./Attachment"; +import type { User } from "./User"; + +/** 通知消息类型 */ +export type NotifyMsgType = "INTERNAL" | "SMS" | "MAIL" | "HTTP" | "WECHAT"; + +/** 通知投递状态 */ +export type NotifyStatus = "WAITING" | "REMINDED" | "TIMEOUT" | "CANCEL" | "FAIL"; + +/** 通知内容类型 */ +export type NotifyContentType = "TEXT" | "TIP_TAP"; + +/** 通知 */ +export type Notify = { + /** 业务类型 */ + bizType?: string; + /** 提醒业务类型 */ + bizRemindedType?: string; + /** 提醒业务 ID */ + bizRemindedId?: string; + /** 参数 */ + args?: string; + /** 创建者 */ + createdBy?: string; + /** 通知详情列表 */ + detailList?: NotifyDetail[]; +} & Model; + +/** 管理端通知列表项 */ +export type NotifyManagementItem = { + /** 创建者用户 ID */ + createdBy?: string; + /** 通知标题 */ + subject?: string; + /** 通知内容 */ + content?: string; + /** 内容类型 */ + contentType?: NotifyContentType; + /** 发送时间 */ + sendAt?: number; + /** 接收用户数量 */ + recipientCount?: number; + /** 已读用户数量 */ + readCount?: number; + /** 等待发送数量 */ + waitingCount?: number; + /** 已发送数量 */ + remindedCount?: number; + /** 超时数量 */ + timeoutCount?: number; + /** 已取消数量 */ + cancelCount?: number; + /** 失败数量 */ + failCount?: number; +} & Model; + +/** 通知详情 */ +export type NotifyDetail = { + /** 所属通知 ID */ + notifyId?: string; + /** 消息类型 */ + msgType?: NotifyMsgType; + /** 模板 ID */ + templateId?: string; + /** 标题 */ + subject?: string; + /** 内容 */ + content?: string; + /** 内容类型 */ + contentType?: NotifyContentType; + /** 媒体附件列表 */ + attachmentList?: Attachment[]; + /** 参数 */ + args?: string; + /** 发送来源 */ + sendFrom?: string; + /** 发送去向 */ + sendTo?: string; + /** 发送时间 */ + sendAt?: number; + /** 重试计数 */ + retry?: number; + /** 超时时间 */ + timeoutAt?: number; + /** 结果描述 */ + resultDesc?: string; + /** 投递状态 */ + status?: NotifyStatus; + /** 已读时间,站内通知未读时为空 */ + readAt?: number; + /** 所属通知 */ + notify?: Notify; + /** 发送用户 */ + fromUser?: User; + /** 接收用户 */ + toUser?: User; +} & Model; diff --git a/src/types/Permission.ts b/src/types/Permission.ts new file mode 100644 index 0000000..06de198 --- /dev/null +++ b/src/types/Permission.ts @@ -0,0 +1,24 @@ +export type ModuleCode = "CORE" | "GAO"; + +export interface Permission { + id: string; + moduleCode: ModuleCode; + code: string; + nameLangId?: string; + name?: string; + description?: string; + builtin?: boolean; + protectedPermission?: boolean; + ownerType?: AuthOwnerType; + ownerId?: string; + createdBy?: string; + createdAt?: number; + updatedAt?: number; + deletedAt?: number; +} + +export type AuthOwnerType = "SYSTEM" | "MODULE" | "TENANT" | "STORE" | "USER"; + +export type PermissionPayload = Pick & { + id?: string; +}; diff --git a/src/types/Role.ts b/src/types/Role.ts new file mode 100644 index 0000000..271daaa --- /dev/null +++ b/src/types/Role.ts @@ -0,0 +1,41 @@ +import type { AuthOwnerType, ModuleCode, Permission } from "./Permission"; +import type { Model } from "./Model"; + +export type Role = { + moduleCode: ModuleCode; + code: string; + nameLangId?: string; + name?: string; + description?: string; + builtin?: boolean; + protectedRole?: boolean; + parentRoleId?: string; + ownerType?: AuthOwnerType; + ownerId?: string; + createdBy?: string; + + permissionList?: Permission[]; + allPermissionList?: Permission[]; + childRoleList?: Role[]; + parentRole?: Role; + delegationPermissionList?: Permission[]; + permissionIdList?: string[]; + childRoleIdList?: string[]; + delegationPermissionIdList?: string[]; +} & Model; + +export type RolePayload = Partial> & { + id?: string; + parentRoleId?: string; + ownerType?: AuthOwnerType; + ownerId?: string; + permissionIdList?: string[]; + childRoleIdList?: string[]; + delegationPermissionIdList?: string[]; +}; + +export interface AuthorizeUserRole { + userId: string; + moduleCode?: ModuleCode; + roleIdList?: string[]; +} diff --git a/src/types/Setting.ts b/src/types/Setting.ts new file mode 100644 index 0000000..41884a7 --- /dev/null +++ b/src/types/Setting.ts @@ -0,0 +1,27 @@ +import type {Model} from "./Model"; + +export type SettingValueType = + | "STRING" + | "SELECT_RADIO" + | "SELECT_CHECKBOX" + | "INTEGER" + | "COLOR" + | "FLOAT" + | "DATE" + | "DATETIME" + | "TIME" + | "DURATION" + | "BOOLEAN" + | "JSON_LIST" + | "JSON_OBJECT" +; + +export type Setting = { + module: string; + key: string; + title?: string; + description?: string; + value?: string; + valueType: SettingValueType; + valueArgs?: any; +} & Model; diff --git a/src/types/User.ts b/src/types/User.ts new file mode 100644 index 0000000..ad20820 --- /dev/null +++ b/src/types/User.ts @@ -0,0 +1,98 @@ +import { ImageType } from "./Model"; +import type { Model } from "./Model"; +import type { Attachment } from "./Attachment"; +import type { Setting } from "./Setting"; +import type { Role } from "./Role"; + +export type Gender = "MALE" | "FEMALE"; + +export type User = { + name: string; + nick?: string; + password?: string; + email?: string; + emailVerifyAt?: number; + phoneNo?: string; + phoneNoVerifyAt?: number; + wrapperType?: ImageType; + avatarType?: ImageType; + exp?: number; + gender?: Gender; + birthdate?: number; + qq?: string; + description?: string; + lastLoginIP?: string; + lastLoginAt?: number; + unmuteAt?: number; + unbanAt?: number; + + roleList?: string[]; + roleEntityList?: Role[]; + permissionList?: string[]; + attachmentList?: Attachment[]; + settingList?: Setting[]; + tempFileIdList?: string[]; + muting?: boolean; + banning?: boolean; + emailVerified?: boolean; + phoneNoVerified?: boolean; +} & Model; + +/** 用户资料组件使用的用户视图。 */ +export type UserView = { + id?: string; + name: string; + profile: { + wrapperType?: ImageType; + avatarType?: ImageType; + exp?: number; + sex?: number; + description?: string; + attachmentList?: Attachment[]; + }; +}; + +export type UserAttachType = "AVATAR" | "WRAPPER"; + +export type RegisterRequest = { + name: string; + password: string; + email?: string; +}; + +export type LoginRequest = { + user: string; + password: string; +}; + +export type SmsLoginRequest = { + detailId: string; + captcha: string; +}; + +export type UpdatePasswordRequest = { + oldValue: string; + newValue: string; +}; + +// 登录返回 +export type LoginResponse = { + id: string; + token: string; + expireAt: number; + user?: User; +}; + +export type LoginUser = { + id?: string; + token?: string; + expireAt?: number; + user?: User; +}; + +export type UserLevelType = { + exp: number; // 经验数值,和 UserData.exp 一样 + value: number; // 经验对应等级,[0, 8] + percent: number; // 该经验在该等级的百分比 [0, 1] + nextLevelUp: number; // 下一级经验值 +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..b976f0b --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,10 @@ +export * from "./Article"; +export * from "./Attachment"; +export * from "./Comment"; +export * from "./Developer"; +export * from "./Model"; +export * from "./Notify"; +export * from "./Permission"; +export * from "./Role"; +export * from "./Setting"; +export * from "./User"; diff --git a/src/utils/Network.ts b/src/utils/Network.ts new file mode 100644 index 0000000..e805716 --- /dev/null +++ b/src/utils/Network.ts @@ -0,0 +1,118 @@ +import axios, { type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from "axios"; +import type { ApiResponse } from "../types"; + +/** 当前后端服务的响应包装。 */ +export type ErrorCallback = (response: ApiResponse) => void; +export type RequestInterceptor = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig; +export interface ResponseObserverPayload { + config?: InternalAxiosRequestConfig; + status?: number; + response?: AxiosResponse; + error?: unknown; + serverResponse?: ApiResponse; +} +export type ResponseObserver = (payload: ResponseObserverPayload) => void; + +let globalErrorCallback: ErrorCallback | null = null; +let globalRequestInterceptor: RequestInterceptor | null = null; +let globalResponseObserver: ResponseObserver | null = null; + +export const setGlobalErrorCallback = (callback: ErrorCallback) => { + globalErrorCallback = callback; +}; + +export const setGlobalRequestInterceptor = (callback: RequestInterceptor | null) => { + globalRequestInterceptor = callback; +}; + +export const setGlobalResponseObserver = (callback: ResponseObserver | null) => { + globalResponseObserver = callback; +}; + +const notifyResponseObserver = (payload: ResponseObserverPayload) => { + if (!globalResponseObserver) { + return; + } + globalResponseObserver(payload); +}; + +const applyDefaultInterceptors = (instance: AxiosInstance) => { + instance.defaults.withCredentials = true; + instance.interceptors.request.use((config) => { + if (globalRequestInterceptor) { + return globalRequestInterceptor(config); + } + return config; + }); + instance.interceptors.response.use((axiosResp: AxiosResponse) => { + if (!axiosResp.config.responseType) { + const serverResp = axiosResp.data as ApiResponse; + if (serverResp.code < 40000) { + notifyResponseObserver({ + config: axiosResp.config, + status: axiosResp.status, + response: axiosResp, + serverResponse: serverResp + }); + return serverResp.data; + } + notifyResponseObserver({ + config: axiosResp.config, + status: axiosResp.status, + response: axiosResp, + serverResponse: serverResp, + error: serverResp.message + }); + if (globalErrorCallback) { + globalErrorCallback(serverResp); + } + return Promise.reject(serverResp.message); + } + notifyResponseObserver({ + config: axiosResp.config, + status: axiosResp.status, + response: axiosResp + }); + return axiosResp.data; + }, (error: unknown) => { + const axiosError = error as { + config?: InternalAxiosRequestConfig; + response?: AxiosResponse; + request?: unknown; + message?: string; + }; + notifyResponseObserver({ + config: axiosError.config, + status: axiosError.response?.status, + response: axiosError.response, + error + }); + if (axiosError.response?.status) { + throw error; + } + if (axiosError.request) { + if (axiosError.message?.startsWith("timeout")) { + throw new Error("time out"); + } + throw new Error(axiosError.message || "request error"); + } + throw error; + }); +}; + +const axiosInstance = axios.create(); +applyDefaultInterceptors(axiosInstance); + +export const createServiceAxios = (baseURL?: string) => { + const instance = axios.create({ baseURL }); + applyDefaultInterceptors(instance); + return instance; +}; + +export default { + axios: axiosInstance, + setGlobalErrorCallback, + setGlobalRequestInterceptor, + setGlobalResponseObserver, + createServiceAxios +}; diff --git a/src/utils/User.ts b/src/utils/User.ts new file mode 100644 index 0000000..21a7018 --- /dev/null +++ b/src/utils/User.ts @@ -0,0 +1,22 @@ +import type { UserLevelType } from "../types/User"; + +/** 根据经验值计算用户等级。 */ +export function toUserLevel(exp = 0): UserLevelType { + const levels = [0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]; + for (let i = 1; i < levels.length; i++) { + if (exp < levels[i]) { + return { + exp, + value: i - 1, + percent: (exp - levels[i - 1]) / (levels[i] - levels[i - 1]), + nextLevelUp: i === levels.length - 1 ? 4096 : levels[i] + }; + } + } + return { + exp, + value: 8, + percent: 1, + nextLevelUp: 4096 + }; +} diff --git a/src/utils/directives/Access.ts b/src/utils/directives/Access.ts new file mode 100644 index 0000000..8a2945a --- /dev/null +++ b/src/utils/directives/Access.ts @@ -0,0 +1,227 @@ +import type { Directive, DirectiveBinding, EffectScope } from "vue"; +import { effectScope, watch } from "vue"; +import { useUserStore } from "../../store"; +import type { AccessMatchMode } from "../../store"; + +/** 访问控制类型 */ +export type AccessControlType = "permission" | "role"; +/** 指令结果生效方式 */ +export type AccessEffectMode = "if" | "show"; + +/** 自定义回调参数 */ +export type AccessDirectiveCallbackPayload = { + type: AccessControlType; + granted: boolean; + valueList: string[]; + mode: AccessMatchMode; + el: HTMLElement; +} + +/** + * 访问控制指令配置 + * `value` 支持单个值或值数组 + * 数组默认按 `or` 逻辑处理,需要 `and` 时显式传 `mode` + * `callback` 存在时优先执行回调,不再使用 `effect` + */ +export type AccessDirectiveConfig = { + value: string | string[]; + mode?: AccessMatchMode; + effect?: AccessEffectMode; + reverse?: boolean; + display?: string; + callback?: (payload: AccessDirectiveCallbackPayload) => void; +} + +type AccessDirectiveValue = string | string[] | AccessDirectiveConfig; + +type AccessDirectiveState = { + scope: EffectScope; + placeholder: Comment | null; + display: string; + config: AccessDirectiveConfig; + lastGranted?: boolean; +} + +type AccessMatcher = (store: ReturnType, valueList: string[], mode: AccessMatchMode) => boolean; + +const ACCESS_STATE_KEY = "__vAccessState"; + +/** 过滤空字符串并统一裁剪空白 */ +function normalizeValueList(value: string | string[]): string[] { + return (Array.isArray(value) ? value : [value]) + .map(item => item?.trim()) + .filter((item): item is string => !!item); +} + +/** 统一把指令值转成标准配置 */ +function normalizeConfig(value: AccessDirectiveValue): AccessDirectiveConfig { + if (typeof value === "string" || Array.isArray(value)) { + return { + value, + mode: "or", + effect: "if", + reverse: false + }; + } + + return { + value: value?.value || [], + mode: value?.mode || "or", + effect: value?.effect || "if", + reverse: !!value?.reverse, + display: value?.display, + callback: value?.callback + }; +} + +/** 恢复被 `if` 模式替换掉的真实元素 */ +function restoreElement(el: HTMLElement, state: AccessDirectiveState): void { + if (state.placeholder?.parentNode) { + state.placeholder.parentNode.replaceChild(el, state.placeholder); + } + state.placeholder = null; +} + +/** `if` 模式:无权限时直接移出 DOM */ +function applyIfEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { + if (granted) { + restoreElement(el, state); + return; + } + + if (!state.placeholder && el.parentNode) { + state.placeholder = document.createComment("v-access"); + el.parentNode.replaceChild(state.placeholder, el); + } +} + +/** `show` 模式:保留 DOM,仅切换 display */ +function applyShowEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { + restoreElement(el, state); + el.style.display = granted ? state.display : "none"; +} + +/** 自定义模式:交给调用方自己处理样式或行为 */ +function applyCustomCallback( + el: HTMLElement, + state: AccessDirectiveState, + granted: boolean, + type: AccessControlType, + valueList: string[], + mode: AccessMatchMode +): void { + restoreElement(el, state); + if (state.lastGranted === granted) { + return; + } + + state.config.callback?.({ + type, + granted, + valueList, + mode, + el + }); +} + +/** 根据当前登录用户状态刷新指令效果 */ +function updateAccess( + el: HTMLElement, + state: AccessDirectiveState, + type: AccessControlType, + matcher: AccessMatcher +): void { + const store = useUserStore(); + const config = state.config; + const mode = config.mode || "or"; + const valueList = normalizeValueList(config.value); + const granted = config.reverse ? !matcher(store, valueList, mode) : matcher(store, valueList, mode); + + if (config.callback) { + applyCustomCallback(el, state, granted, type, valueList, mode); + } else if (config.effect === "show") { + applyShowEffect(el, state, granted); + } else { + applyIfEffect(el, state, granted); + } + + state.lastGranted = granted; +} + +function getState(el: HTMLElement): AccessDirectiveState | undefined { + return (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; +} + +function setState(el: HTMLElement, state: AccessDirectiveState): void { + (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY] = state; +} + +function clearState(el: HTMLElement): void { + delete (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; +} + +/** 卸载时清掉占位注释,避免遗留无用节点 */ +function clearPlaceholder(state: AccessDirectiveState): void { + if (state.placeholder?.parentNode) { + state.placeholder.parentNode.removeChild(state.placeholder); + } + state.placeholder = null; +} + +/** 创建角色/权限通用指令 */ +function createAccessDirective(type: AccessControlType, matcher: AccessMatcher): Directive { + return { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const state: AccessDirectiveState = { + scope: effectScope(), + placeholder: null, + display: el.style.display, + config: normalizeConfig(binding.value) + }; + + setState(el, state); + + state.scope.run(() => { + const store = useUserStore(); + watch( + () => { + return type === "permission" ? store.getPermissionList().join("|") : store.getRoleList().join("|"); + }, + () => updateAccess(el, state, type, matcher), + { immediate: true } + ); + }); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + const state = getState(el); + if (!state) { + return; + } + + state.config = normalizeConfig(binding.value); + updateAccess(el, state, type, matcher); + }, + unmounted(el: HTMLElement) { + const state = getState(el); + if (!state) { + return; + } + + state.scope.stop(); + clearPlaceholder(state); + clearState(el); + } + }; +} + +/** 权限指令:`v-can` */ +export const VCan = createAccessDirective("permission", (store, valueList, mode) => { + return store.hasPermissions(valueList, mode); +}); + +/** 角色指令:`v-role` */ +export const VRole = createAccessDirective("role", (store, valueList, mode) => { + return store.hasRoles(valueList, mode); +}); + +export default VCan; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..66e9860 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export { default as VCan } from "./directives/Access"; +export * from "./directives/Access"; +export { default as Network } from "./Network"; +export * from "./Network"; +export { toUserLevel } from "./User"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..591f261 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "rootDir": "src", + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], + "baseUrl": ".", + "paths": { + "timi-web": [ + "../timi-web/dist/src/index.d.ts" + ], + "timi-web/editor": [ + "../timi-web/dist/src/editor/index.d.ts" + ], + "vue": [ + "../timi-web/node_modules/vue" + ], + "pinia": [ + "../timi-web/node_modules/pinia" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ] +}