22 Commits
Author SHA1 Message Date
ops 808f02bac7 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:28 +00:00
ops 6701cbac76 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:59 +00:00
chenxing 90338f3980 对接规范落地:版本锁1.3.24/操作列actionColumn text/表格撑满新模型/撤配色切换器/超管门槛+外观三键
build / build (push) Successful in 2m54s
2026-10-11 21:58:08 +08:00
chenxing 729ab9592d feat(sso): 治理面接入 CAS 登录往返——标记头+code302 整页跳转+记账回页签
build / build (push) Successful in 1m32s
- alova-core 通用头补 yschapirequest:1:后端 CAS 门据此对 SPA 出 200+{code:302},
  errorHandle 的 case 302 整页跳登录,跳转前 rememberReturnUrl 记账、返回挂起
  Promise 不再触发全局错误弹窗(Notice 同款)
- ssoReturn.ts:回跳目标记 sessionStorage(同页签跨站往返仍在),只认同 origin
  同壳页的地址防开放重定向;不照 Notice 拼 hex 进 service——本网关 ctx 为空、
  外链前缀被 Caddy 剥离,固定 service 与每次不同的目标不可共存(会票据校验死循环)
- main.ts 挂载前 consumeSsoReturnUrl 换回原页签,避免回跳闪默认页
- 重载注册表按钮改走浏览器面孪生入口 /api/internal/admin/routes/reload
  (机器面 /routes/refresh 收口内部令牌后浏览器再打会被 401 拦死)
- 顶栏登出:ssoHost 取自已登录会话 /admin/session(后端无 env-config 端点),
  取不到不渲染;mock 同步补 reload/session 两端点
2026-10-11 12:50:23 +08:00
chenxing a036f39713 chore(theme): YschTheme sync 件更新(版本 20261009-1107-1688389)
build / build (push) Successful in 1m51s
2026-10-09 11:13:06 +08:00
chenxing 3ed3af3962 chore(theme): theme.ts 同步通道 1038 版本戳(LOGO文字与主题JSON化) 2026-10-09 10:49:42 +08:00
chenxing 3acbc79008 chore(theme): YschTheme sync 件更新(版本 20261009-1018-fb1c7f6) 2026-10-09 10:38:22 +08:00
chenxing 85a7f563f6 chore(theme): theme.ts 同步通道新版本戳(LOGO文字与主题JSON化) 2026-10-09 10:36:33 +08:00
chenxing bed01788fd chore(theme): YschTheme sync 件更新(版本 20261009-0054-5732ee2) 2026-10-09 00:54:50 +08:00
chenxing 0dac2d3b6e chore(theme): YschTheme sync 件更新(版本 20261009-0036-19f025a) 2026-10-09 00:36:34 +08:00
chenxing 449ad16244 chore(theme): YschTheme sync 件更新(版本 20261008-2326-f7fb534,oapi 代理轮) 2026-10-09 00:23:43 +08:00
chenxing 77060325ef feat(mock): mock 演进轮——应用列表/代理测试页 + 网关 mock 数据扩充
- views/app/AppList.vue(新)+ views/proxytest/ProxyTest.vue(新):应用列表与代理测试演示页
- router/routes + apiDefinitions + App.vue/layout:新页路由与菜单接线
- assets/mock/gatewayApi.ts:mock 数据扩充(+136 行)
- RouteList:列表页联动增强
- vite.config:mock 相关配置调整
- theme.ts:sync 刷新至 20261006-2013-75c1d10 并补回本仓唯一允许适配(顶部 // @ts-nocheck,strict vue-tsc 契约);typecheck 零错误
2026-10-06 20:22:02 +08:00
chenxing 8a21d85036 docs: MOCK.md——mock 演示指南(一条命令/走查点/数据清单/契约对照/差异/主题说明) 2026-10-01 23:45:33 +08:00
chenxing ff0f79a473 fix(app): 照 Notice 锁定视口——html/body overflow hidden + main-wrapper 内部滚动 2026-10-01 23:44:10 +08:00
chenxing 5b886f4fc2 fix(typecheck): strict vue-tsc 通过——types 加 node、theme.ts 顶部 @ts-nocheck 本地适配 2026-10-01 23:14:43 +08:00
chenxing 1db85be875 feat(scripts): dev:mock 一键离线演示(本地主题通道 9527 + vite mock 8131) 2026-10-01 23:10:36 +08:00
chenxing 7d6560703d feat(mock): 内置 mock 模式——严格契约中间件 + 种子数据(--mode mock 专用) 2026-10-01 23:10:08 +08:00
chenxing 8c8147cd5b feat(theme): 接入 YschTheme 中央静态通道(A 类 loader + sync 四件 + layout 骨架对齐) 2026-10-01 23:07:36 +08:00
chenxing cded9030b5 feat(front): 路由页 authMode 收敛为 ANON,移除已退役的治理小页映射
build / build (push) Successful in 1m33s
- RouteList.vue 下拉项 ANONYMOUS → ANON,与后端白名单及 DDL 值域一致
- apiDefinitions.ts 删 GatewayAdmin.page 映射(后端 page 端点与 static/admin.html 已退役,
  重载注册表由头部按钮承接)
- LedgerList.vue 注释去掉对已删 admin.html 的引用
2026-09-24 10:30:55 +08:00
chenxing 3fd608be7e fix(front): 冒烟修复——dateFormat 兼容 ISO 时间串、去掉破坏深链接的 onMounted 重定向、消除双 toast
build / build (push) Successful in 2m23s
- 时间列 NaN:网关 Jackson 序列化为 ISO(2026-09-23T14:20:12.333),dateFormat 斜杠化后还须去 T
- 深链接:冷启动时初始导航未解析完 currentRoute 恒为 /,onMounted 的 replace 会把
  #/routes、#/targets(含刷新)全部弹回台账;/ 落点本就由 routes.ts 的 redirect 处理
- 业务错误由 alova 全局 onBizError 统一弹出,页面 catch 只做回滚,不再重复弹
- 浏览器实测:台账筛选/详情弹窗、路由列与只读列、重载注册表(22 路由/14 目标)、
  目标页空 baseUrl 保存被后端拒绝且 UI 回滚,全部通过
2026-09-23 14:49:49 +08:00
chenxing 73dbbcb06c Merge remote-tracking branch 'origin/main'
build / build (push) Successful in 2m49s
2026-09-23 14:28:13 +08:00
chenxing bed87ced52 feat(front): 网关台账前端——台账/路由/目标三页+重载注册表
- 与 notice 同形态:hash 路由 + TablePageAlovaNew 分页 + alova 信封分支
- API 层手写(文档端点仅容器网内可达,api:gen 无通路),键名对齐后端 operationId
- 审计修复:outDir 回 dist 根(CI zip 根须含 html)、main.ts 显式引入 @/api 挂载全局 Apis、
  保存失败重拉回滚、路由页补只读 timeout/bodyLimit 列、详情弹窗 backendCode 恒空说明
2026-09-23 14:28:10 +08:00
101 changed files with 14651 additions and 8 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
src
public
config
dist/base
dist/ld
dist/_verify
.git
.workbuddy
*.log
vite.config.ts.timestamp-*
+10
View File
@@ -0,0 +1,10 @@
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。
# serviceHost 用同源前缀 /open-gateway,由 vite.config.ts 的 server.proxy 转发到 base 域,
# 避免跨源:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1。
# 线上产物不读本文件(build 为 production mode),serviceHost 由 host.config.ts 同域推导
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
VITE_SERVICE_HOST=/open-gateway
VITE_IS_MOCK=false
# 超管门槛(src/utils/superGate.ts):本地无平台 Cookie 时 CurrentUser 探测失败按放行处理;
# 如需完全旁路门槛,取消下一行注释(仅 dev 生效,build 不读本文件)。
# VITE_SUPER_GATE=off
+7
View File
@@ -0,0 +1,7 @@
# 本地纯前端演示模式:仅 vite --mode mock 加载(推荐入口 npm run dev:mock,
# 由 scripts/start-mock.mjs 一并拉起 9527 主题静态通道;直接 vite --mode mock 也可,
# 但无 VPN 时主题 CSS 取不到、只剩骨架)。build 产物不读任何 .env.*。
# 后端/VPN 不可达时,vite dev server 内置中间件按后端契约拦截 /open-gateway/api/internal/**
# (见 vite.config.ts gatewayAdminMockPlugin + src/assets/mock/gatewayApi.ts)。
VITE_SERVICE_HOST=/open-gateway
VITE_IS_MOCK=true
+19 -8
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl / zip(alpine) - name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,21 +41,29 @@ jobs:
- name: 构建前端(node 容器) - name: 构建前端(node 容器)
run: | run: |
set -e set -e
docker rm -f frontbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name frontbuild -w /build node:20 sleep 3600 # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
docker cp "${PWD}/." frontbuild:/build/ # 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
docker exec frontbuild sh -c ' # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e set -e
cd /build cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci npm ci
npm run build npm run build
' '
echo "=== dist 产物 ===" echo "=== dist 产物 ==="
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20' docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录 # 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f frontbuild docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK" echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件) # 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.workbuddy/
*.log
.idea/
+3
View File
@@ -0,0 +1,3 @@
SASS_BINARY_PATH =d:\发布区\win32-x64-115_binding.node
registry =https://registry.npmmirror.com
node_version = 20.19.4
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
extends: ["airbnb", "prettier", "prettier/react"],
singleQuote: false, // 是否强制使用单引号
printWidth: 120, // 超过最大值换行
tabWidth: 4,
htmlWhitespaceSensitivity: "ignore",
semi: true, // 结尾是否使用分号
//disableLanguages: ["vue"], // 不格式化vue文件,vue文件的格式化单独设置
endOfLine: "auto",
arrowParens: "avoid", // 箭头函数参数括号 默认avoid 可选 avoid| always
};
//如需显示webpack目录,运行以下命令
// npm run eject
//如需配合使eslint生效,运行以下命令
//npm install --save-dev eslint-plugin-prettier
+87
View File
@@ -0,0 +1,87 @@
# 网关台账前端 Mock 演示指南
离线(无 VPN、无后端)一键起完整可演示环境:三个治理页全量可看可玩 + YschTheme 主题五档配色。
## 一条命令
```cmd
cd D:\SoftProject\yunhai\open-gateway\front
npm run dev:mock
```
然后浏览器打开 **http://localhost:8131**(自动落在「访问台账」页)。
前提:Node **20.19.4**(本机 nvm 路径 `D:\nvm\nvm\v20.19.4`,若 `node -v` 不对请先 `nvm use 20.19.4`),`front/node_modules` 已安装。
`dev:mock`(`scripts/start-mock.mjs`,零依赖)做三件事:
1. 拉起 **YschTheme 本地静态通道**:`YschTheme/scripts/dev-serve.mjs` 把 `YschTheme/dist/pc` 挂到 `http://localhost:9527/static/`(主题 CSS/JS 不拷进本仓——应用侧不持主题副本是主题仓硬原则);
2. 探活 `9527/static/manifest.json`;
3. 以 `YH_THEME_PROXY_TARGET=http://localhost:9527` 启动 `vite --mode mock`(8131),vite 的 `/static` 代理由此指向本地通道。
全程不触网。`Ctrl+C` 退出会一并收掉两个子进程;若 9527 被上次残留进程占用且可服务,脚本会直接复用。
### 切回真后端
装好 VPN 后照旧 `npm run dev`(走 `.env.dev`,经 `/open-gateway/api` 代理到 base 域),与 `dev:mock` 互不影响、互不覆盖。线上 build(`npm run build`)不读任何 `.env.*`,行为与 mock 无关。
## 每页看什么、玩什么
| 页面 | 看点 / 操作 |
| --- | --- |
| **/ledger 访问台账** | 233 条调用记录,时间倒序。翻页(24 页);`target` 与 `调用方 appKey` 精确筛选(试试 `notice` + `100001` → 共 5 条;`callerAppKey` 填 `anonymous` 可筛匿名调用,填了查不到的值=空列表);「查看详情」弹窗;状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红;「后端码」恒 `-`(流式转发不解析业务 code) |
| **/routes 路由注册表** | 28 行路由。行内改 `visibility`(INTERNAL/EXTERNAL)、`authMode`(SIGNED/ANON)、缓存秒数/范围、qps、启停、备注 → 「保存」→ toast「已保存,注册表加载 N 条路由」;离开页面再进来,值保持(内存态);把缓存秒数改成负数保存 → 后端同款报错 toast(`cacheTtlSeconds 非法:-5(须为 ≥0 的整数)`) |
| **/targets 目标地址** | 7 个生产 ctx。行内改 `baseUrl` → 保存 → toast「已保存,目标表加载 7 条」;`baseUrl` 清空保存 → 报错 toast;启停开关 |
| **顶栏** | 「重载注册表」→ toast「已重载:路由 28 条 / 目标 7 条」+「最后更新 HH:mm:ss」;「手动刷新」重建当前页;MOCK 徽标(仅 mock 模式出现) |
| **右上「配色」胶囊** | 五档主题色(原版靛蓝/云海蓝/石墨墨/靛紫/松石绿)+ 侧栏深浅/侧栏跟随/顶部渐变三开关;选择持久化在 localStorage(`yh-theme`),刷新/重开浏览器保持 |
**Mock 数据的存活性**:PUT 改动只存在于 vite 进程内存——站内切换页面改动**保持**;**浏览器整页刷新/直达(F5)即重置**回静态种子;重启 `dev:mock` 也重置。台账 `ts` 以重置时刻为终点向过去铺 6.5 天,所以永远是"近期"。
## Mock 数据清单
数据源:`src/assets/mock/gatewayApi.ts`(种子 + 确定性 LCG,改种子改这里)。
- **targets(7)**:userbase、userclass、yschcms、svc-hall、flow-center、usermsg、notice;`baseUrl = http://{targetCode}-service:8080`(照 `service/docs/sql/03_target.sql` 语义)。
- **routes(28)**:
- 22 行 = `service/docs/sql/02_init_routes.sql` 全量种子(全保守档 INTERNAL×SIGNED、cacheTtl 0、qps 0,备注原样照抄);
- 6 行治理样本:id23 `yschcms /oapi/site/render/**`(EXTERNAL×ANON,GLOBAL 缓存 600s,qps 50)、id24 `svc-hall /oapi/svc/items/**`(EXTERNAL×ANON,CALLER 缓存 60s)、id25 `notice /oapi/notice/public/**`(EXTERNAL×ANON,GLOBAL 300s,qps 100)、id26 `userbase /oapi/user/batchAdd`(EXTERNAL×SIGNED,qps 5 熔断)、id27 `usermsg /oapi/msg/health`(INTERNAL×ANON,**空备注**样本)、id28 `flow-center /oapi/flow/diagram/**`(**停用**行样本)。
- 四种 visibility×authMode 组合、空备注、停用行、差异化 ttl/qps/remark 全覆盖。
- **logs(233)**:appKey 取实测值 100001/100007/100009/100010/100013(约六成行),`"anonymous"` 约 46 行,`callerAppKey=null`(未携带 appKey 被网关拒)约 51 行;status 覆盖 200/401/403/404/500(约 125/69/30/3/6);costMs 3~1602ms 快慢兼有;path 为该 target 路由形态(`/**` 或具体 id 段);backendCode 恒 null。
## 与真实接口的契约对照
统一信封 `{code:0, msg:"", data:X}`;业务非法一律 **HTTP 200 + `{code:2, msg:"…"}`**(前端 alova 层弹 msg);分页 data 形状 `{total, content, pageIndex, pageSize, totalPages, hasNext}`。
| 接口 | 契约 | mock 实现 |
| --- | --- | --- |
| `GET /api/internal/admin/routes?pageIndex&pageSize` | 排序 targetCode,pathPattern;无 keyword 参数 | 一致(localeCompare 排序) |
| `PUT /api/internal/admin/routes/{id}` | body 只认 7 键:visibility/authMode/cacheTtlSeconds/cacheScope/rateLimitQps/enabled/remark;visibility∈{INTERNAL,EXTERNAL}、authMode∈{SIGNED,ANON}、cacheScope∈{CALLER,GLOBAL};两 int 须 Number 且 ≥0;**enabled 须真布尔——传字符串 `"true"` 一律按 false 落库(后端真实坑,照抄)**;成功 `data={loaded:路由总条数}`;非法→`{code:2,msg:"visibility 非法:XXX(合法 [EXTERNAL, INTERNAL])"}` | 全部一致,错误消息格式一字不差 |
| `GET /api/internal/admin/targets` | 同信封分页 | 一致 |
| `PUT /api/internal/admin/targets/{id}` | body{baseUrl 非空, enabled 真布尔};成功 `data={targets:N}` | 一致 |
| `GET /api/internal/admin/logs?pageIndex&pageSize&targetCode&callerAppKey` | pageSize 默认 50 上限 500;筛选**精确等值**、空串=不过滤;排序 id desc;`ts` ISO 字符串;backendCode 恒 null | 一致(先过滤再分页) |
| `POST /api/internal/admin/routes/reload` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7;`apps` 键 mock 无数据源,前端按 `-` 渲染) |
| `GET /api/internal/admin/session` | `data={ssoHost,principal}`,值来自已登录 CAS 会话 | `ssoHost` 返空串 → 顶栏「退出」不渲染(同 Notice 的 ssoHost 空态) |
前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。
## 与真后端的差异(mock 不造假接口)
- **无登录/CAS**(后端本就没有,治理面靠 Caddy 白名单);顶栏用户区是静态占位「网关治理」。
- **无 keyword 模糊搜索**(routes 接口没有该参数,别造)。
- **无路由/目标的新增与删除**(后端只支持行内编辑)。
- **无统计、无时间过滤**(M1 范围,后端尚无接口)。
- **无 caller 管理**页(appKey 只出现在台账里)。
- 刷新/重启即重置;真实后端当然是持久化的。
## 主题说明(YschTheme 中央静态通道)
- **接入方式**:`index.html` 头部一段同步 loader(照 Notice 配方,A 类免插件)引 `/static/js/yh-theme-init.js`;host 优先级 `__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(dev 可注入)> 同源 /static/`。网关无 env-config,恒走后两档;build 不替换占位符 → 线上走同源通道,零环境值烧入。
- **本地联调**:vite `/static` 代理 → `YH_THEME_PROXY_TARGET`(dev:mock 指本地 9527;`npm run dev` 时指 base 域——**无 VPN 时 `npm run dev` 主题 CSS 取不到,页面剩骨架无皮肤**,这正是 dev:mock 存在的原因)。
- **档位**:五档配色 + 侧栏深浅 + 侧栏跟随 + 顶部渐变,状态存 localStorage(`yh-theme`),由 `/static/js/yh-theme-init.js` 在首帧前挂 `<html>` 档位类(防 FOUC)。
- **sync 两件**:`src/utils/avatar.ts`、`src/components/UserAvatar.vue` 由 YschTheme 下发(consumers.json 已登记 open-gateway 条目)。2026-10-11 对接规范轮裁撤 `theme.ts` + `ThemeSwitcher.vue`(§3 红线:子应用不做配色切换器),mock 模式配色档位走通道默认档 + `/open-gateway/api/appearance` mock 端点收敛。
## 实现备注
- mock 走 **vite.config.ts 手写 `configureServer` 中间件**(仅 `--mode mock` 注册,先于内部代理),而非常规 `vite-plugin-mock`:后者用 esbuild 独立打包 mock 文件,与 vite 配置是两个模块实例,无法实现「整页刷新重置内存态」;且零新依赖(红线:不引运行时新依赖)。
- `.env.mock` 仅两行:`VITE_SERVICE_HOST=/open-gateway` + `VITE_IS_MOCK=true`(后者驱动顶栏 MOCK 徽标,dev/线上恒不出现)。
- 非 mock 模式(`npm run dev` / `npm run build`)行为与改造前一致(唯一例外:布局样式改由主题通道下发,本地 `layout.css` 快照已删除)。
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 主题通道引导(A 类免插件接入,照 Notice 配方):唯一接入点,不装依赖、无需 env-config。
origin 零写死,优先级:__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(仅 dev 可注入)
> 同源 "/static/…"(生产常态:通道与应用同域;本地 dev/mock 由 vite /static 代理兜到通道,
dev:mock 时 scripts/start-mock.mjs 把代理目标指到本地 9527,无 VPN 离线可跑)。
网关后端无 env-config 端点,__APP_CONFIG__ 恒为空 → 实际恒走后两档。
document.write 保证 parser-blocking:档位挂载与 FOUC 门先于首帧;build 不解析内联脚本字符串,
占位符原样留字面量 → 判首字符 % 置空,产物零环境值烧入。 -->
<script>
(function () {
var c = window.__APP_CONFIG__ || {};
var themeHost = "%VITE_THEME_HOST%";
if (themeHost.charAt(0) === "%") themeHost = "";
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
})();
</script>
<title><%- title %></title>
<%- injectScript %>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+9135
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "open-gateway-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"dev:mock": "node scripts/start-mock.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "vite build --mode base",
"build:base": "vite build --mode base",
"preview": "vite preview"
},
"dependencies": {
"alova": "^3.4.0",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.24",
"vue": "^3.4.19",
"vue-router": "4"
},
"devDependencies": {
"@types/node": "^20.4.4",
"@unocss/preset-rem-to-px": "^66.6.0",
"@unocss/preset-uno": "^66.6.0",
"@vitejs/plugin-vue": "^5.0.4",
"sass-loader": "^10.0.1",
"typescript": "^5.2.2",
"unocss": "^66.6.0",
"unplugin-auto-import": "0.18.3",
"vite": "^5.1.4",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^2.2.12"
}
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* start-mock.mjs —— 无 VPN 一键离线演示(npm run dev:mock)。
*
* 做三件事(零第三方依赖,Node 20 自带 fetch):
* 1. 拉起 YschTheme 的本地静态通道 scripts/dev-serve.mjs(9527,/dist/pc 挂 /static/);
* 2. 探活 9527/static/manifest.json(若端口已被上次残留进程占用且可服务,同样放行);
* 3. 以 YH_THEME_PROXY_TARGET=http://localhost:9527 spawn vite --mode mock(8131)。
*
* 效果:浏览器访问 http://localhost:8131 —— 三页治理台账全量可演示(mock 数据),
* 主题 CSS/切换器经本地 9527 供给,全程不触网、不需要 VPN。
* 主题静态资源不拷进 front/public(应用侧不持主题副本是主题仓硬原则)。
* VPN 恢复后照旧 npm run dev 走真后端,两者互不影响。Ctrl+C 退出会一并收掉两个子进程。
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const themeDir = path.resolve(frontDir, "..", "..", "YschTheme");
const themeServeScript = path.join(themeDir, "scripts", "dev-serve.mjs");
const viteBin = path.join(frontDir, "node_modules", "vite", "bin", "vite.js");
const THEME_PORT = 9527;
const fail = (msg) => {
console.error(`[start-mock] ${msg}`);
process.exit(1);
};
if (!existsSync(themeServeScript)) {
fail(`找不到主题静态服务脚本 ${themeServeScript}(YschTheme 仓位置异常?)`);
}
if (!existsSync(viteBin)) {
fail(`找不到 ${viteBin}(先在 front 目录 npm install)`);
}
/* 1. 本地静态通道(YschTheme 零依赖实现,serve dist/pc 于 /static/) */
const themeServer = spawn(process.execPath, [themeServeScript], {
cwd: themeDir,
stdio: ["ignore", "pipe", "pipe"],
});
themeServer.stdout.on("data", (d) => process.stdout.write(`[theme] ${d}`));
themeServer.stderr.on("data", (d) => process.stderr.write(`[theme] ${d}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitThemeReady(tries = 24) {
for (let i = 0; i < tries; i++) {
try {
const res = await fetch(`http://localhost:${THEME_PORT}/static/manifest.json`);
if (res.ok) return true;
} catch {
/* 尚未就绪/端口被占(占位进程若可服务也会在上面返回 ok) */
}
await sleep(250);
}
return false;
}
/* 2. 探活:主题通道就绪才起前端(演示强依赖主题 CSS) */
if (!(await waitThemeReady())) {
themeServer.kill();
fail(`9527 主题静态通道未就绪 —— 检查 ${themeServeScript} 与 YschTheme/dist/pc 是否存在(必要时先在 YschTheme 跑 npm run gen)`);
}
console.log(`[start-mock] 主题静态通道就绪 → http://localhost:${THEME_PORT}/static/`);
/* 3. vite --mode mock(dev server 8131;/static 代理由 YH_THEME_PROXY_TARGET 指向 9527) */
const vite = spawn(process.execPath, [viteBin, "--mode", "mock"], {
cwd: frontDir,
stdio: "inherit",
env: { ...process.env, YH_THEME_PROXY_TARGET: `http://localhost:${THEME_PORT}` },
});
const shutdown = () => {
vite.kill();
themeServer.kill();
};
vite.on("exit", (code) => {
themeServer.kill();
process.exit(code ?? 0);
});
themeServer.on("exit", () => {
/* 9527 若由残留进程提供则本子进程会因端口占用退出,探活已放行,不必终止演示 */
});
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
shutdown();
process.exit(0);
});
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, ref, onMounted, reactive } from "vue";
import layout from "./components/layout.vue";
import { LayoutData } from "./types/Basics";
import { checkSuperAdmin, SuperGate } from "@/utils/superGate";
const isRouterAlive = ref(true), isLogin = ref(false);
/* 超管门槛(对接规范 §10 open-gateway 轮):挂载前探测 /userbase/base/User/CurrentUser,
isSuperAdmin 明确为假 → 整页「暂无权限」;接口不可达(error)不锁死治理面——
访问控制真身在后端 CAS 门,本门槛是体验层。pending 期间不渲染,避免默认页闪一帧。 */
const gate = ref<SuperGate>("pending");
const userName = ref("");
const gateReady = computed(() => gate.value !== "pending");
const denied = computed(() => gate.value === "denied");
/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
const modList = [
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
{ name: "RouteList", title: "路由注册表", page: "/routes" },
{ name: "TargetList", title: "目标地址", page: "/targets" },
{ name: "AppList", title: "应用管理", page: "/apps" },
{ name: "ProxyTest", title: "代理测试", page: "/proxy-test" },
];
const layoutData: LayoutData = reactive({
isAlone: false,
appName: "开放网关",
appList: [],
modList: [],
userInfo: null,
baseHost: null,
ssoHost: null,
});
const reload = () => {
isRouterAlive.value = false;
setTimeout(() => {
isRouterAlive.value = true;
}, 0);
};
provide("reload", reload);
onMounted(async () => {
layoutData.modList = modList;
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
isLogin.value = true;
const s = await checkSuperAdmin();
gate.value = s.gate;
userName.value = s.name;
if (s.gate === "error") {
// 探测不通不拦(本地无平台 Cookie / userbase 故障),仅留痕便于排障
console.warn("[superGate] CurrentUser 探测失败,按放行处理", );
}
});
const getRouterKey = (route: any) => route?.name;
</script>
<template>
<template v-if="gateReady && !denied">
<layout v-if="isLogin" :layoutData="layoutData" :user-name="userName"></layout>
<div v-if="isLogin" class="base-wrap-main">
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<component :is="Component" :key="getRouterKey(route)" />
</transition>
</router-view>
</div>
</template>
<!-- 非超级管理员:整页「暂无权限」(主内容区语义,页面级空态不带菜单/顶栏) -->
<div v-if="denied" class="gate-denied">
<div class="gate-denied-card">
<Icon type="ios-locked-outline" class="gate-denied-icon" />
<h3 class="gate-denied-title">暂无权限</h3>
<p class="gate-denied-desc">开放网关控制台仅对超级管理员开放{{ userName ? `,当前账号:${userName}` : "" }}</p>
</div>
</div>
</template>
<style lang="scss">
.fade-enter-active,
.fade-leave-active {
transition: all 1ms cubic-bezier(0, 0.99, 1, 0.03);
}
.fade-enter {
opacity: 0;
}
/* 照 Notice 配方锁定视口:顶栏/侧栏/面包屑均 fixed,若 body 可滚,整页滚动会让内容
从 fixed 导航下方穿过(错位遮挡);改为 main-wrapper 内部滚动 */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
}
.base-wrap-main .main-wrapper {
height: 100%;
overflow-y: auto;
}
/* 通道撑满模型(§4):列表页根节点 page-wrapper yh-fill,flex 纵向铺满、表格区实测高 */
.base-wrap-main .page-wrapper {
height: 100%;
}
/* 暂无权限页:整幅居中,配色全走主题令牌(禁写死品牌色,§1) */
.gate-denied {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--page-bg, #f5f7fa);
}
.gate-denied-card {
text-align: center;
padding: 48px 64px;
background: var(--card-bg, #fff);
border-radius: 16px;
}
.gate-denied-icon {
font-size: 44px;
color: var(--brand);
}
.gate-denied-title {
margin: 16px 0 8px;
font-size: 18px;
color: var(--text-main, #17233d);
}
.gate-denied-desc {
margin: 0;
font-size: 13px;
color: var(--text-sub, #808695);
}
</style>
+23
View File
@@ -0,0 +1,23 @@
/**
* 网关治理管理面接口定义(手写)。
* <p>
* 说明:open-gateway 文档端点 {@code /v3/api-docs} 属网关自身路径(RoutingFilter 已白名单旁路),
* 但出口仅容器网内可达,本地/CI 无法执行 alova wormhole 的 api:gen,故本文件手写。
* 键名 = 后端 {@code @Operation(operationId=...)}(见 InternalAdminController / RouteRefreshController),
* 保持与 UserBase/Notice 生成物同形:{@code [method, path]}。
*/
export default {
'GatewayAdmin.routes': ['GET', '/api/internal/admin/routes'],
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'],
'GatewayAdmin.apps': ['GET', '/api/internal/admin/apps'],
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'],
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
// 重载注册表:走浏览器面孪生入口。机器面 /api/internal/routes/refresh 只认内部令牌,
// 浏览器带 CAS 会话去打它会被 401 拦死(两口共用 RegistryReloader,返回体同形)
'GatewayAdmin.reload': ['POST', '/api/internal/admin/routes/reload'],
'GatewayAdmin.session': ['GET', '/api/internal/admin/session'],
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
};
+87
View File
@@ -0,0 +1,87 @@
/// <reference types='./globals.d.ts' />
/* tslint:disable */
/* eslint-disable */
/**
* 接口调用代理工厂(与 UserBase/Notice 同形,来源为 alova 生成器的运行时部分)。
* 依据 apiDefinitions 的 [method, url] 构造 alova Method;{path} 占位由 pathParams 替换。
*/
import { Method } from 'alova';
import apiDefinitions from './apiDefinitions';
const cache = Object.create(null);
const createFunctionalProxy = (
array: (string | symbol)[],
alovaInstance: Alova<any, any, any, any, any>,
configMap: any
) => {
const apiPathKey = array.join('.') as keyof typeof apiDefinitions;
if (cache[apiPathKey]) {
return cache[apiPathKey];
}
// create a new proxy instance
const proxy = new Proxy(function () {}, {
get(_, property) {
// record the target property, so that it can get the completed accessing paths
const newArray = [...array, property];
// always return a new proxy to continue recording accessing paths.
return createFunctionalProxy(newArray, alovaInstance, configMap);
},
apply(_, __, [config]) {
const apiItem = apiDefinitions[apiPathKey];
if (!apiItem) {
throw new Error(`the api path of \`${apiPathKey}\` is not found`);
}
const mergedConfig = {
...configMap[apiPathKey],
...config
};
const [method, url] = apiItem;
const pathParams = mergedConfig.pathParams;
const urlReplaced = url!.replace(/\{([^}]+)\}/g, (_, key) => {
const pathParam = pathParams[key];
return pathParam;
});
delete mergedConfig.pathParams;
let data = mergedConfig.data;
if (Object.prototype.toString.call(data) === '[object Object]' && typeof FormData !== 'undefined') {
let hasBlobData = false;
const formData = new FormData();
for (const key in data) {
if (Array.isArray(data[key])) {
for (const ele of data[key]) {
formData.append(key, ele);
if (ele instanceof Blob) {
hasBlobData = true;
}
}
} else {
formData.append(key, data[key]);
if (data[key] instanceof Blob) {
hasBlobData = true;
}
}
}
data = hasBlobData ? formData : data;
}
return new Method(method!.toUpperCase() as MethodType, alovaInstance, urlReplaced, mergedConfig, data);
}
});
cache[apiPathKey] = proxy;
return proxy;
};
export const createApis = (alovaInstance: Alova<any, any, any, any, any>, configMap: any) => {
const Apis = new Proxy({} as Apis, {
get(_, property) {
return createFunctionalProxy([property], alovaInstance, configMap);
}
});
return Apis;
};
export const mountApis = (Apis: Apis) => {
// define global variable `Apis`
(globalThis as any).Apis = Apis;
};
export const withConfigType = <Config extends MethodsConfigMap>(config: Config) => config;
+37
View File
@@ -0,0 +1,37 @@
/* 手写精简版(替代 alova wormhole 生成物):只声明本工程 Apis 代理机制所需的最小类型。
网关无 api:gen 通路(文档端点仅容器网内可达),故不生成全量接口类型。
所有类型必须挂在 declare global 下——createApis.ts 是裸引用(不 import)。 */
declare global {
type MethodType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
/** 调用配置(params/pathParams/data/cacheFor 等,宽松即可) */
interface Alova2MethodConfig {
params?: Record<string, any>;
pathParams?: Record<string, any>;
data?: any;
headers?: Record<string, any>;
cacheFor?: any;
[key: string]: any;
}
/** alova 实例占位(仅作为 createApis 形参,不参与类型推导) */
type Alova<T1 = any, T2 = any, T3 = any, T4 = any, T5 = any> = any;
/** Api 方法返回形态(实际为 alova Method 实例) */
type ApiMethod = any;
/** 按命名空间分组的接口代理(如 Apis.GatewayAdmin.logs(...)) */
interface Apis {
[namespace: string]: {
[method: string]: (config?: Alova2MethodConfig) => ApiMethod;
};
}
interface MethodsConfigMap {
[key: string]: Alova2MethodConfig;
}
const Apis: Apis;
}
export {};
+11
View File
@@ -0,0 +1,11 @@
import { createApis, withConfigType, mountApis } from "./createApis";
import { alovaInstance } from "@/utils/alova/alova-iview";
export const $$userConfigMap = withConfigType({});
const Apis = createApis(alovaInstance, $$userConfigMap);
mountApis(Apis);
export default Apis;
+81
View File
@@ -0,0 +1,81 @@
import { BaseConf } from "@/types/Basics";
/**
* 配置优先级(高 → 低):
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<应用>/<前端目标>/api/env-config?target=xxx(projman 配置管理在线维护、双写 Nacos)写入
* ② import.meta.env.VITE_*:仅本地开发(vite dev --mode dev 读 .env.dev)——值可自由指本地
* 或线上地址,供联调切换;build(production mode)时 .env/.env.production 均不存在,
* VITE_* 恒为 undefined,本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 拉取失败(线上后端/Nacos 不可用)时框架至少能渲染;
* ssoHost/relayConf 为空 → 登录与跨域中转不可用——与后端不可用状态一致,可接受。
*/
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/userbase/front/ → userbase),与服务端 context-path 对齐
const ctxSeg = (window.location.pathname.split("/").filter(Boolean)[0] || "") as string;
const origin = window.location.origin;
let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: ctxSeg ? `${origin}/${ctxSeg}` : origin,
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isMock: false,
isDebug: env.DEV,
};
// 逐键覆盖:只覆盖来源里实际出现且非空的键(含 relayConf 二级逐键合并),未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
const { relayConf, ...flat } = source;
Object.keys(flat).forEach(key => {
if (flat[key] !== undefined && flat[key] !== null && flat[key] !== "") {
(target as any)[key] = flat[key];
}
});
if (relayConf && typeof relayConf === "object") {
if (!target.relayConf || typeof target.relayConf !== "object") target.relayConf = {};
Object.keys(relayConf).forEach(key => {
if (relayConf[key]) target.relayConf[key] = relayConf[key];
});
}
};
// ② 本地开发(.env.dev;构建产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_SERVICE_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
isMock: env.VITE_IS_MOCK === "true",
});
// ① 线上运行时配置(最高优先,覆盖前面所有)
applyConf(conf, (window as any).__APP_CONFIG__);
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
if ("https:" === document.location.protocol) {
Object.keys(conf).forEach(element => {
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
conf[element] = conf[element].replace("http://", "https://");
}
});
// relayConf 内部也需要处理
if (conf.relayConf) {
Object.keys(conf.relayConf).forEach(key => {
const val = (conf.relayConf as any)[key];
if (typeof val === "string" && val.indexOf("http://") > -1) {
(conf.relayConf as any)[key] = val.replace("http://", "https://");
}
});
}
}
export default conf;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path transform="translate(-56.6 -56.8) scale(1.11111)" d="M555 427Q555 444 542.5 456.5Q530 469 512.0 469.0Q494 469 481.5 456.5Q469 444 469 427V128Q469 117 475.0 107.0Q481 97 491.0 91.0Q501 85 512.0 85.0Q523 85 533.0 91.0Q543 97 549.0 107.0Q555 117 555 128ZM657 293Q646 287 640.0 275.5Q634 264 635.5 251.5Q637 239 645.5 229.0Q654 219 666 216Q683 211 698 219Q783 266 835.0 347.5Q887 429 894.5 525.5Q902 622 863.0 710.5Q824 799 747 858Q679 911 595.5 929.5Q512 948 428.5 929.5Q345 911 276.5 858.0Q208 805 169.5 729.0Q131 653 128.0 567.5Q125 482 159 403Q184 344 227.0 297.0Q270 250 326 219Q341 211 357.5 216.0Q374 221 382.5 236.0Q391 251 386.5 267.5Q382 284 367 293Q308 326 269.5 380.0Q231 434 218.5 499.0Q206 564 223.0 629.0Q240 694 282.5 745.0Q325 796 385.0 824.5Q445 853 512.0 853.0Q579 853 639.0 824.5Q699 796 741.5 745.0Q784 694 801.0 629.0Q818 564 805.5 499.0Q793 434 754.5 380.0Q716 326 657 293Z"/></svg>

After

Width:  |  Height:  |  Size: 1017 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M41.65 64.5995C42.942 65.8915 45.05 66.2995 46.75 65.4495C49.742 64.1575 52.292 62.8995 54.842 60.7575C56.134 59.4655 56.984 57.7995 56.542 55.6575C56.1 53.1075 56.542 50.1155 57.834 47.5655C59.126 45.0155 61.642 43.3155 64.226 42.4655C65.926 42.0235 67.184 40.3235 67.626 38.6575C68.068 35.6655 68.068 32.2655 67.626 29.3075C67.184 27.6075 65.926 25.9075 64.226 25.4655C61.676 24.6155 59.126 22.9155 57.834 20.3655C56.542 17.8155 56.134 14.8235 56.542 12.2735C56.984 10.5735 56.1 8.43153 54.842 7.17353C52.292 5.06553 49.742 3.77353 46.75 2.48153C45.05 1.63153 42.942 2.03953 41.65 3.33153C37.4 7.24153 30.6 7.24153 26.35 3.39953C25.058 2.10753 22.95 1.69953 21.25 2.54953C18.292 3.80753 15.708 5.09953 13.158 7.24153C11.458 8.53353 11.016 10.2335 11.458 12.3415C12.75 17.8495 9.35 23.7995 3.842 25.4995C1.7 25.9415 0.442 27.6415 0.442 29.3415C0 31.0415 0 32.2995 0 33.9995C0 35.6995 0 36.9915 0.442 38.6915C0.442 40.3915 1.734 42.0915 3.842 42.4995C6.392 43.3495 8.942 45.0495 10.234 47.5995C11.526 50.1495 11.934 53.1415 11.492 55.6915C11.05 57.3915 11.934 59.4995 13.192 60.7915C15.742 62.9335 18.292 64.1915 21.284 65.4835C22.984 66.3335 25.126 65.9255 26.384 64.6335C30.6 60.3495 37.4 60.3495 41.65 64.5995Z" fill="#919BF2"/>
<path d="M24.208 34C24.208 35.2859 24.4613 36.5592 24.9534 37.7472C25.4455 38.9353 26.1667 40.0147 27.076 40.924C27.9853 41.8333 29.0647 42.5545 30.2528 43.0466C31.4408 43.5387 32.7141 43.792 34 43.792C35.2859 43.792 36.5592 43.5387 37.7472 43.0466C38.9353 42.5545 40.0147 41.8333 40.924 40.924C41.8333 40.0147 42.5545 38.9353 43.0466 37.7472C43.5387 36.5592 43.792 35.2859 43.792 34C43.792 32.7141 43.5387 31.4408 43.0466 30.2528C42.5545 29.0648 41.8333 27.9853 40.924 27.076C40.0147 26.1667 38.9353 25.4455 37.7472 24.9534C36.5592 24.4613 35.2859 24.208 34 24.208C32.7141 24.208 31.4408 24.4613 30.2528 24.9534C29.0647 25.4455 27.9853 26.1667 27.076 27.076C26.1667 27.9853 25.4455 29.0648 24.9534 30.2528C24.4613 31.4408 24.208 32.7141 24.208 34Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7245_84837)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7245_84837">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7245_84837)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7245_84837">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7245_84837)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7245_84837">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7245_84837)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7245_84837">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7245_84837)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M58.5962 56.782C57.2642 55.486 56.1722 54.142 55.3202 52.75C54.2762 54.346 52.8962 55.678 51.1802 56.746L50.4602 55.576C52.2602 54.532 53.6462 53.182 54.6182 51.526C53.8502 50.062 53.2622 48.478 52.8542 46.774C52.5182 47.434 52.1162 48.064 51.6482 48.664L50.8742 47.692C52.1462 45.856 53.0102 43.384 53.4662 40.276L54.7442 40.492C54.6122 41.332 54.4382 42.184 54.2222 43.048H58.8482V44.29H57.7322C57.6482 47.122 57.0722 49.564 56.0042 51.616C56.9162 53.164 58.0442 54.508 59.3882 55.648L58.5962 56.782ZM53.8622 44.29L53.5022 45.28C53.9342 47.128 54.5222 48.796 55.2662 50.284C56.0342 48.532 56.4542 46.534 56.5262 44.29H53.8622ZM43.8542 42.004H46.2122V40.402H47.4542V42.004H49.3802V43.156H47.4542V44.992H48.4982C49.4342 43.804 50.2562 42.502 50.9642 41.086L51.9722 41.608C51.3122 42.856 50.6282 43.984 49.9202 44.992H51.2522V46.198H49.0022C48.4862 46.81 47.9942 47.35 47.5262 47.818H50.5862V48.754C49.9742 49.474 49.2542 50.194 48.4262 50.914V51.796C49.5422 51.628 50.6102 51.406 51.6302 51.13V52.336C50.6942 52.588 49.6262 52.798 48.4262 52.966V55.054C48.4262 56.098 47.9162 56.62 46.8962 56.62H45.0962L44.7722 55.45C45.5282 55.51 46.0682 55.54 46.3922 55.54C46.9082 55.54 47.1662 55.264 47.1662 54.712V53.146C45.8342 53.302 44.5142 53.44 43.2062 53.56L43.0262 52.318C44.3342 52.258 45.7142 52.144 47.1662 51.976V50.662C47.8382 50.158 48.4682 49.594 49.0562 48.97H46.2662C45.3182 49.75 44.3882 50.392 43.4762 50.896L42.6662 49.978C43.1942 49.678 43.7342 49.342 44.2862 48.97H43.8722V47.818H45.7802C46.3682 47.326 46.9322 46.786 47.4722 46.198H43.0082V44.992H46.2122V43.156H43.8542V42.004Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7245_84837">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7186_97095)">
<path d="M52.5096 4.53333H43.443V2.26667C43.443 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2942 0 23.043 0C21.7918 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70484 4.53333 2.64297 8.5952 2.64297 13.6V54.4C2.64297 59.4048 6.70484 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.519 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.243 48.8512 16.243 47.6C16.243 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9654 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.243 35.2512 16.243 34C16.243 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9654 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.243 21.6512 16.243 20.4C16.243 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9654 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M44.1242 40.672C45.2522 41.032 46.2482 41.452 47.1122 41.932L46.4282 42.976C45.4922 42.424 44.5142 41.98 43.4942 41.644L44.1242 40.672ZM51.1982 40.402C51.0062 40.93 50.8502 41.314 50.7302 41.554H57.9842V42.328C57.7682 43.228 57.4802 44.104 57.1202 44.956L55.8782 44.578C56.1662 44.05 56.4242 43.42 56.6522 42.688H53.5922C53.5202 43.12 53.4122 43.516 53.2682 43.876C54.2042 45.256 56.0582 46.21 58.8302 46.738L58.3622 47.89C55.7222 47.35 53.8502 46.366 52.7462 44.938C52.5182 45.286 52.2782 45.592 52.0262 45.856C51.1262 46.696 49.6802 47.35 47.6882 47.818L47.2022 46.666C48.9782 46.282 50.2562 45.754 51.0362 45.082C51.6962 44.47 52.1342 43.672 52.3502 42.688H50.0462C49.5422 43.336 48.9482 43.93 48.2642 44.47L47.5082 43.462C48.6722 42.61 49.4762 41.536 49.9202 40.24L51.1982 40.402ZM56.7602 48.304V53.65H55.4642V49.51H46.4822V53.794H45.1862V48.304H56.7602ZM51.7562 50.302C51.5042 52.582 50.9582 54.118 50.1182 54.91C49.1702 55.894 47.0822 56.512 43.8542 56.764L43.3502 55.612C46.2182 55.444 48.1082 54.952 49.0202 54.136C49.8362 53.356 50.3162 52.042 50.4602 50.194L51.7562 50.302ZM47.3822 44.938C46.4822 45.874 45.3662 46.756 44.0342 47.584L43.1702 46.576C44.5262 45.808 45.6782 44.968 46.6262 44.056L47.3822 44.938ZM52.1702 53.596C54.4982 54.208 56.6282 54.946 58.5602 55.81L57.8582 56.89C55.7822 55.906 53.6642 55.138 51.5042 54.586L52.1702 53.596Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7186_97095">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7186_97102)">
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M46.2482 45.172H47.4902V47.962H49.3082V49.15H47.4902V51.67C48.1742 51.526 48.8222 51.382 49.4342 51.238V52.444L47.4902 52.894V56.818H46.2482V53.128C45.2402 53.308 44.1362 53.47 42.9362 53.614L42.7742 52.354C44.2382 52.186 45.3962 52.03 46.2482 51.886V49.15H43.2602L42.9902 48.016C43.5062 46.888 43.9862 45.484 44.4302 43.804H42.8282V42.562H44.7362C44.9402 41.65 45.0962 40.894 45.2042 40.294L46.4282 40.492L45.9962 42.562H49.2542V43.804H45.6902C45.2702 45.424 44.8142 46.81 44.3222 47.962H46.2482V45.172ZM49.8302 42.58H53.6822V40.366H54.9062V42.58H57.3002C56.9042 42.04 56.4182 41.464 55.8422 40.852L56.7062 40.312C57.3662 41.008 57.8762 41.608 58.2362 42.112L57.5702 42.58H59.2082V43.804H54.9062V45.46H58.5962V55.234C58.5962 56.218 58.1462 56.71 57.2462 56.71H56.0042L55.6802 55.486L56.8322 55.54C57.2042 55.54 57.3902 55.354 57.3902 54.982V52.84H54.9062V56.674H53.6822V52.84H51.3242V56.782H50.1182V45.46H53.6822V43.804H49.8302V42.58ZM53.6822 46.612H51.3242V48.646H53.6822V46.612ZM51.3242 49.762V51.724H53.6822V49.762H51.3242ZM57.3902 51.724V49.762H54.9062V51.724H57.3902ZM57.3902 48.646V46.612H54.9062V48.646H57.3902Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7186_97102">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7186_97108)">
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M55.2302 55.288C56.7302 55.288 58.0802 55.264 59.2802 55.216L59.0642 56.512H55.5182C51.2102 56.512 48.5162 56.278 47.4362 55.81C46.5002 55.414 45.6542 54.688 44.8982 53.632C44.5742 54.784 44.1242 55.846 43.5482 56.818L42.6122 55.99C43.5722 54.358 44.1002 52.516 44.1962 50.464L45.3482 50.608C45.3122 51.256 45.2522 51.826 45.1682 52.318C45.6362 53.05 46.1522 53.626 46.7162 54.046V49.672H43.1522V48.484H50.7842V49.672H47.8682V51.58H50.3342V52.732H47.8682V54.694C47.9762 54.73 48.0662 54.766 48.1382 54.802C49.1822 55.126 51.5462 55.288 55.2302 55.288ZM43.9622 40.996H50.1002V47.278H43.9622V40.996ZM48.8402 46.198V44.668H45.2222V46.198H48.8402ZM45.2222 43.66H48.8402V42.076H45.2222V43.66ZM51.0362 41.014H58.8122V42.202H55.5722C55.5122 42.73 55.4342 43.21 55.3382 43.642H58.3082V50.914H57.1022V44.758H52.7462V50.914H51.5942V43.642H54.1682C54.2642 43.234 54.3422 42.754 54.4022 42.202H51.0362V41.014ZM56.2202 50.968C57.3362 51.832 58.2722 52.696 59.0282 53.56L58.1102 54.46C57.4142 53.596 56.4962 52.708 55.3562 51.796L56.2202 50.968ZM54.3302 45.604H55.5182V48.142C55.4822 49.834 55.1522 51.202 54.5282 52.246C53.9282 53.182 52.9322 53.926 51.5402 54.478L50.8382 53.362C52.1222 52.894 53.0282 52.252 53.5562 51.436C54.0362 50.572 54.2942 49.474 54.3302 48.142V45.604Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7186_97108">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7186_97114)">
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M50.4602 48.826H48.7682V47.62H53.4302V48.826H51.7382V53.704C52.5902 53.512 53.3702 53.314 54.0782 53.11V54.316C52.5182 54.76 50.8322 55.138 49.0202 55.45L48.6962 54.226C49.3082 54.154 49.8962 54.064 50.4602 53.956V48.826ZM57.2822 56.53C56.6582 56.53 56.1122 56.062 55.6442 55.126C55.1042 54.046 54.7082 52.576 54.4562 50.716C54.2642 49.192 54.1322 47.218 54.0602 44.794H48.3542V43.534H54.0242C54.0002 42.838 53.9882 41.74 53.9882 40.24H55.2482C55.2482 41.752 55.2602 42.85 55.2842 43.534H58.8662V44.794H55.3022C55.3862 47.83 55.5782 50.218 55.8782 51.958C56.2742 53.998 56.7422 55.054 57.2822 55.126C57.6542 55.054 58.0202 53.95 58.3802 51.814L59.4062 52.642C58.8182 55.234 58.1102 56.53 57.2822 56.53ZM56.7962 40.222C57.5522 41.062 58.1522 41.818 58.5962 42.49L57.6962 43.12C57.2762 42.472 56.6702 41.692 55.8782 40.78L56.7962 40.222ZM44.9162 40.474C46.0442 41.374 47.0102 42.298 47.8142 43.246L46.9322 44.146C46.2002 43.246 45.2282 42.316 44.0162 41.356L44.9162 40.474ZM42.7562 45.802H46.5362V53.65C46.9682 53.23 47.5202 52.654 48.1922 51.922L48.5342 53.29C47.5262 54.382 46.4762 55.336 45.3842 56.152L44.8802 55C45.1442 54.772 45.2762 54.526 45.2762 54.262V47.044H42.7562V45.802Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7186_97114">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7186_97120)">
<path d="M52.5096 4.53333H43.4429V2.26667C43.4429 1.01547 42.4275 0 41.1763 0C39.9251 0 38.9096 1.01547 38.9096 2.26667V4.53333H25.3096V2.26667C25.3096 1.01547 24.2941 0 23.0429 0C21.7917 0 20.7763 1.01547 20.7763 2.26667V4.53333H11.7096C6.70481 4.53333 2.64294 8.5952 2.64294 13.6V54.4C2.64294 59.4048 6.70481 63.4667 11.7096 63.4667H52.5096C57.5144 63.4667 61.5763 59.4048 61.5763 54.4V13.6C61.5763 8.5952 57.5189 4.53333 52.5096 4.53333ZM45.7096 49.8667H18.5096C17.2584 49.8667 16.2429 48.8512 16.2429 47.6C16.2429 46.3488 17.2584 45.3333 18.5096 45.3333H45.7096C46.9608 45.3333 47.9763 46.3488 47.9763 47.6C47.9763 48.8512 46.9653 49.8667 45.7096 49.8667ZM45.7096 36.2667H18.5096C17.2584 36.2667 16.2429 35.2512 16.2429 34C16.2429 32.7488 17.2584 31.7333 18.5096 31.7333H45.7096C46.9608 31.7333 47.9763 32.7488 47.9763 34C47.9763 35.2512 46.9653 36.2667 45.7096 36.2667ZM45.7096 22.6667H18.5096C17.2584 22.6667 16.2429 21.6512 16.2429 20.4C16.2429 19.1488 17.2584 18.1333 18.5096 18.1333H45.7096C46.9608 18.1333 47.9763 19.1488 47.9763 20.4C47.9763 21.6512 46.9653 22.6667 45.7096 22.6667Z" fill="#A0CDF2"/>
<circle cx="51.5" cy="48.5" r="18.5" fill="white"/>
<circle cx="51.6914" cy="48.6912" r="14.6912" fill="#A0CDF2"/>
<path d="M42.9181 45.784H48.0661C48.2221 45.196 48.4081 44.452 48.6241 43.552H44.1421V42.346H48.8941C49.0021 41.854 49.1521 41.128 49.3441 40.168L50.6761 40.276L50.2621 42.346H58.0201V43.552H49.9921C49.8241 44.296 49.6441 45.04 49.4521 45.784H59.0821V47.044H49.1101C48.9421 47.62 48.6841 48.412 48.3361 49.42H56.9041V50.536C56.0161 51.904 54.6601 53.242 52.8361 54.55C53.8321 54.91 54.8701 55.348 55.9501 55.864L55.2301 56.998C52.5661 55.678 49.7821 54.628 46.8781 53.848L47.5621 52.84C48.7621 53.164 50.0581 53.566 51.4501 54.046C53.0941 52.966 54.4021 51.826 55.3741 50.626H46.4821C46.9501 49.366 47.3581 48.172 47.7061 47.044H42.9181V45.784Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_7186_97120">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg
t="1615860353141"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6624"
width="14"
height="14"
>
<path
d="M0 102.4h307.2v307.2H0zM0 614.4h307.2v307.2H0zM409.6 102.4h614.4v102.4H409.6zM409.6 307.2h614.4v102.4H409.6zM409.6 614.4h614.4v102.4H409.6zM409.6 819.2h614.4v102.4H409.6z"
p-id="6625"
fill="#656667"
></path>
</svg>

After

Width:  |  Height:  |  Size: 914 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M832 192H544l-64-64H192c-35.2 0-64 28.8-64 64v576c0 35.2 28.8 64 64 64h640c35.2 0 64-28.8 64-64V256c0-35.2-28.8-64-64-64z m0 576H192V192h266.7l64 64H832v512z" fill="#999999"/>
<path d="M256 320h512v64H256z m0 128h384v64H256z m0 128h256v64H256z" fill="#cccccc"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

+13
View File
@@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="30" height="30" rx="3" fill="white" stroke="#DCDEE0" stroke-width="2" stroke-dasharray="4 4"/>
<g clip-path="url(#clip0_125_1092)">
<path d="M24 8H8V24H24V8Z" fill="white" fill-opacity="0.01"/>
<path d="M16.0209 11.3333L16.0087 20.6666" stroke="#666666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3333 16H20.6667" stroke="#666666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_125_1092">
<rect width="16" height="16" fill="white" transform="translate(8 8)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 695 B

+15
View File
@@ -0,0 +1,15 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="30" height="30" rx="3" fill="white" stroke="#DCDEE0" stroke-width="2"/>
<g clip-path="url(#clip0_125_1099)">
<path d="M24 8H8V24H24V8Z" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6667 13H21.3333L20.3333 22.6667H11.6667L10.6667 13Z" stroke="#F15B43" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M14.6667 16.3333V19.6667" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round"/>
<path d="M17.3333 16.3333V19.6658" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round"/>
<path d="M12 13L17.4414 9L20 13" stroke="#F15B43" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_125_1099">
<rect width="16" height="16" fill="white" transform="translate(8 8)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 919 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path d="M802.5 946H603.8C575 945.9 551.7 922.6 551.6 893.8V694.2H472.8V893.8C472.8 922.5 449.3 946 420.6 946H219.1C190.4 945.9 167.1 922.6 167.1 893.8V566.4H77.8C55.9 566.6 36.3 553 28.8 532.4C21.2 512.1 27 489.2 43.4 475L468.2 95.9L468.5 95.7C496.5 71.5 538.3 72.3 565.4 97.5L981.9 476.2L982 476.5C997.7 491.2 1002.8 513.7 994.7 533.6C986.9 553.5 967.7 566.6 946.3 566.5L854.6 566.5V893.8C854.6 922.5 831.2 946 802.5 946ZM615.2 882.4H791V503H916.7L522.3 144.3L522.2 144.2C518.9 141.1 513.8 140.9 510.3 143.7L107.8 502.8H230.6V882.4H409.2V682.8C409.2 654.2 432.6 630.8 461.4 630.8H563C591.7 630.8 615.2 654.2 615.2 682.8V882.4L615.2 882.4Z" ></path></svg>

After

Width:  |  Height:  |  Size: 766 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M482.214544 543.934407l-0.099952-59.970717 59.970718-0.099952 0.099951 59.970718-59.970717 0.099951z m119.941435-0.199902l-0.099952-59.970718 59.970718-0.099951 0.099951 59.970717-59.970717 0.099952z m119.941434-0.199903l-0.099951-59.970717 59.970718-0.099951 0.099951 59.970717-59.970718 0.099951z m119.941435-0.299853l-0.099951-59.970718 59.970717-0.099951 0.099952 59.970717-59.970718 0.099952z m119.941435-0.199903l-0.099951-59.970717 59.970717-0.099951 0.099951 59.970717-59.970717 0.099951z" />
<path d="M482.914202 1024l-0.099951-59.970717 59.970717-0.099952 0.099951 59.970718-59.970717 0.099951z m-0.199902-119.941435l-0.099952-59.970717 59.970718-0.099951 0.099951 59.970717-59.970717 0.099951z m-0.199903-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099952 59.970717-59.970718 0.099951z m-0.199902-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099951 59.970717-59.970717 0.099951z m-0.099951-119.941434l-0.099952-59.970718 59.970718-0.099951 0.099951 59.970717-59.970717 0.099952z m-0.199903-119.941435l-0.099951-59.970718 59.970718-0.099951 0.099951 59.970718-59.970718 0.099951z m-0.199902-119.941435l-0.099951-59.970718 59.970717-0.099951 0.099951 59.970718-59.970717 0.099951z m-0.199902-119.941435l-0.099952-59.970717 59.970718-0.099952 0.099951 59.970718-59.970717 0.099951z m-0.199903-119.941435l-0.099951-59.970717 59.970717-0.099951 0.099952 59.970717-59.970718 0.099951z" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7225_67996)">
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
<path d="M14.625 2.25H3.375C2.75368 2.25 2.25 2.75368 2.25 3.375V14.625C2.25 15.2463 2.75368 15.75 3.375 15.75H14.625C15.2463 15.75 15.75 15.2463 15.75 14.625V3.375C15.75 2.75368 15.2463 2.25 14.625 2.25Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M9 6V12" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 9H12" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_7225_67996">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 793 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
<path d="M2.625 15.75H16.125" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.125 10.02V12.75H6.86895L14.625 4.99054L11.8857 2.25L4.125 10.02Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+14
View File
@@ -0,0 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7225_67984)">
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
<path d="M7 3V15" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 7L7 3" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 15V3" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 15L15 11" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_7225_67984">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 740 B

+1
View File
@@ -0,0 +1 @@
<svg t="1784779423851" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6101" width="24" height="24"><path d="M1016.32 494.08l-143.872-143.36c-9.728-9.728-26.112-9.728-35.84 0-9.728 9.728-9.728 26.112 0 35.84L936.96 486.4h-399.36V87.04l99.84 100.352c9.728 9.728 26.112 9.728 35.84 0 9.728-9.728 9.728-26.112 0-35.84l-143.36-143.872c-9.728-9.728-26.112-9.728-35.84 0l-143.36 143.872c-9.728 9.728-9.728 26.112 0 35.84 9.728 9.728 26.112 9.728 35.84 0L486.4 87.04v399.36H87.04l100.352-99.84c9.728-9.728 9.728-26.112 0-35.84-9.728-9.728-26.112-9.728-35.84 0l-143.872 143.36c-9.728 9.728-9.728 26.112 0 35.84l143.872 143.36c9.728 9.728 26.112 9.728 35.84 0 9.728-9.728 9.728-26.112 0-35.84L87.04 537.6h399.36v399.36l-99.84-100.352c-9.728-9.728-26.112-9.728-35.84 0-9.728 9.728-9.728 26.112 0 35.84l143.36 143.872c9.728 9.728 26.112 9.728 35.84 0l143.36-143.872c9.728-9.728 9.728-26.112 0-35.84-9.728-9.728-26.112-9.728-35.84 0L537.6 936.96v-399.36h399.36l-100.352 99.84c-9.728 9.728-9.728 26.112 0 35.84 9.728 9.728 26.112 9.728 35.84 0l143.872-143.36c10.24-9.728 10.24-26.112 0-35.84z" p-id="6102"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7225_67990)">
<path d="M18 0H0V18H18V0Z" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 5.625H15L13.875 16.5H4.125L3 5.625Z" stroke="#5B6871" stroke-width="1.125" stroke-linejoin="round"/>
<path d="M7.50073 9.37598V13.1261" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round"/>
<path d="M10.5009 9.375V13.1241" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round"/>
<path d="M4.5 5.62496L10.6216 1.125L13.5 5.625" stroke="#5B6871" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_7225_67990">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 795 B

+1
View File
@@ -0,0 +1 @@
<svg t="1784812882139" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3484" width="24" height="24"><path d="M446.112323 177.545051c137.567677 0.219798 252.612525 104.59798 266.162424 241.493333 13.562828 136.895354-78.778182 261.818182-213.617777 289.008485-134.852525 27.203232-268.386263-52.156768-308.945455-183.608889s25.018182-272.252121 151.738182-325.779394A267.235556 267.235556 0 0 1 446.112323 177.545051m0-62.060607c-182.794343 0-330.989899 148.195556-330.989899 330.989899s148.195556 330.989899 330.989899 330.989899 330.989899-148.195556 330.989899-330.989899-148.195556-330.989899-330.989899-330.989899z m431.321212 793.341415a30.849293 30.849293 0 0 1-21.94101-9.102223l-157.220202-157.220202c-11.752727-12.179394-11.584646-31.534545 0.37495-43.50707 11.972525-11.972525 31.327677-12.140606 43.494141-0.37495l157.220202 157.220202a31.036768 31.036768 0 0 1 6.723232 33.810101 31.004444 31.004444 0 0 1-28.651313 19.174142z m0 0" p-id="3485"></path></svg>

After

Width:  |  Height:  |  Size: 1014 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_161_3866" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3333 0H10.6667V10.6667H0V13.3333H10.6667V24H13.3333V13.3333H24V10.6667H13.3333V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_161_3866)">
<rect x="-4" y="-4" width="32" height="32" fill="#646566"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 476 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_161_2681" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="3" y="3" width="12" height="12">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.7203 3.21967L14.7809 4.28033L10.061 9.00025L14.7809 13.7202L13.7203 14.7809L9.00034 10.0609L4.28039 14.7809L3.21973 13.7202L7.93968 9.00025L3.21975 4.28033L4.28041 3.21967L9.00034 7.93959L13.7203 3.21967Z" fill="white"/>
</mask>
<g mask="url(#mask0_161_2681)">
<rect width="18" height="18" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="47" height="47" rx="23.5" fill="white" stroke="#EBEDF0"/>
<g clip-path="url(#clip0_23_1904)">
<path d="M26.3517 11.612L33.8684 15.829C35.3234 16.645 36.22 18.1532 36.22 19.7854V28.2146C36.22 29.8467 35.3235 31.355 33.8684 32.171L26.3517 36.3879C24.8965 37.2039 23.1035 37.2039 21.6484 36.3879L14.1317 32.171C12.6766 31.355 11.78 29.8467 11.78 28.2146V19.7854C11.78 18.1532 12.6765 16.645 14.1317 15.829L21.6484 11.612C23.1035 10.796 24.8965 10.796 26.3517 11.612ZM22.6913 12.9051L22.4469 13.0283L14.9303 17.2454C14.0639 17.7312 13.5079 18.5857 13.4213 19.5268L13.4093 19.7855V28.2146C13.4093 29.169 13.8932 30.0625 14.7021 30.6132L14.9307 30.7548L22.4468 34.9715C23.3263 35.4647 24.3975 35.5058 25.3088 35.095L25.5532 34.9716L33.0698 30.7545C33.9362 30.2687 34.4922 29.4142 34.5788 28.4732L34.5908 28.2144V19.7854C34.5908 18.8309 34.1069 17.9374 33.2981 17.3867L33.0694 17.2452L25.5533 13.0285C24.6738 12.5353 23.6026 12.4942 22.6913 12.905L22.6913 12.9051ZM23.9977 16.3019C24.4446 16.3019 24.8529 16.5478 25.0514 16.9368L26.7634 20.3038L30.5874 20.843C31.0306 20.9055 31.3986 21.207 31.5369 21.6205C31.6751 22.0341 31.5597 22.4882 31.239 22.7916L28.4732 25.4116L29.127 29.1123C29.2029 29.5411 29.0214 29.9745 28.659 30.2302C28.4797 30.3557 28.2695 30.43 28.0512 30.4452C27.8328 30.4603 27.6144 30.4156 27.4195 30.316L24 28.5688L20.5805 30.3162C20.3856 30.4158 20.1673 30.4604 19.9489 30.4453C19.7306 30.4301 19.5205 30.3558 19.3412 30.2303C18.9786 29.9746 18.7972 29.5413 18.8731 29.1123L19.527 25.4117L16.7564 22.7916C16.4357 22.4882 16.3202 22.0341 16.4585 21.6206C16.5968 21.2069 16.9648 20.9056 17.4078 20.843L21.2319 20.3039L22.944 16.9369C23.1425 16.5478 23.5507 16.3019 23.9977 16.3019V16.3019ZM23.9968 18.4555L22.301 21.7944L18.6183 22.3132L21.2834 24.8335L20.6436 28.4573L24 26.7428L27.3565 28.4575L26.7168 24.8344L29.3768 22.3133L25.6944 21.7945L23.9967 18.4556L23.9968 18.4555Z" fill="#5979EA"/>
</g>
<defs>
<clipPath id="clip0_23_1904">
<rect width="26" height="26" fill="white" transform="translate(11 11)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

+581
View File
@@ -0,0 +1,581 @@
/**
* 网关治理管理面本地 Mock(vite --mode mock 专用,由 vite.config.ts 的 configureServer
* 中间件在 dev server 内拦截 /open-gateway/api/internal/**,零依赖、零新插件)。
*
* 背景:本机无内网 VPN 时 base 域不可达,三页治理台账全空。本文件按后端
* InternalAdminController / RouteRefreshController 的真实响应契约提供内存数据,
* 仅供前端离线演示/走查,字段结构与信封与线上后端保持一致:
* - 统一信封 {code:0, msg:"", data:X};业务非法一律 HTTP 200 + {code:2, msg:"…"};
* - 分页 data 形状 {total, content, pageIndex, pageSize, totalPages, hasNext};
* - PUT routes 只认 7 个治理键;enabled 必须真布尔(字符串 "true" 一律按 false 落库,
* 照抄后端真实坑);两个 int 字段须 Number 且 ≥0;
* - 后端没有的接口不 mock(登录/CAS、caller CRUD、统计、路由/目标增删均不存在)。
*
* 数据形态:静态种子 + 内存态——PUT 后再 GET 可见新值;整页刷新(HTML 文档请求)
* 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { createHmac } from "node:crypto";
/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */
type Visibility = "INTERNAL" | "EXTERNAL";
type AuthMode = "SIGNED" | "ANON";
type CacheScope = "CALLER" | "GLOBAL";
export interface OpenApiRoute {
id: number;
targetCode: string;
pathPattern: string;
httpMethod: string;
visibility: Visibility;
authMode: AuthMode;
cacheTtlSeconds: number;
cacheScope: CacheScope;
rateLimitQps: number;
timeoutMs: number;
requestBodyLimitMb: number;
enabled: boolean;
remark: string;
gmtCreate: string;
gmtModified: string;
}
export interface OpenApiTarget {
id: number;
targetCode: string;
baseUrl: string;
enabled: boolean;
gmtCreate: string;
gmtModified: string;
}
export interface OpenApiLog {
id: number;
ts: string;
callerAppKey: string | null;
targetCode: string;
method: string;
path: string;
status: number;
backendCode: null;
costMs: number;
}
interface PageResult<T> {
total: number;
content: T[];
pageIndex: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
}
/* ============================ 静态种子 ============================ */
const GMT_BASE = "2026-09-12T02:00:00.000Z";
const GMT_TUNE = "2026-09-26T06:30:00.000Z";
/** 7 个生产 ctx(照 service/docs/sql/03_target.sql 语义:容器网内直连 base-url) */
const SEED_TARGETS: OpenApiTarget[] = [
{ id: 1, targetCode: "userbase", baseUrl: "http://userbase-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 2, targetCode: "userclass", baseUrl: "http://userclass-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 3, targetCode: "yschcms", baseUrl: "http://yschcms-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 4, targetCode: "svc-hall", baseUrl: "http://svc-hall-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 5, targetCode: "flow-center", baseUrl: "http://flow-center-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 6, targetCode: "usermsg", baseUrl: "http://usermsg-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 7, targetCode: "notice", baseUrl: "http://notice-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
];
/**
* 路由种子:service/docs/sql/02_init_routes.sql 的 22 行全保守种子(INTERNAL×SIGNED)打底,
* 另补 6 行治理后的样本:EXTERNAL×ANON 豁免 ×3、EXTERNAL×SIGNED ×1、INTERNAL×ANON ×1、
* 停用行 ×1、空备注 ×1,覆盖全部枚举组合与差异化 cacheTtl/qps/remark。
*/
const SEED_ROUTES: OpenApiRoute[] = [
// ---- 02_init_routes.sql 种子(22 行,remark 原样照抄)----
{ id: 1, targetCode: "userbase", pathPattern: "/oapi/app/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "应用信息:byId/list", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 2, targetCode: "userbase", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "组织:tree/byId/list/users/add/edit/delete/batchAdd/move", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 3, targetCode: "userbase", pathPattern: "/oapi/identity/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "身份:current/available/switch", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 4, targetCode: "userbase", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "用户:byId/byLoginName/byPhone/modules/add/edit/delete/batchAdd 等 21 端点", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 5, targetCode: "userclass", pathPattern: "/oapi/DbClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:班级", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 6, targetCode: "userclass", pathPattern: "/oapi/Common/FamCom/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:家属", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 7, targetCode: "userclass", pathPattern: "/oapi/SchGroup/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校组织", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 8, targetCode: "userclass", pathPattern: "/oapi/SchInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 9, targetCode: "userclass", pathPattern: "/oapi/SubInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学科", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 10, targetCode: "userclass", pathPattern: "/oapi/Schoolen/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学段", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 11, targetCode: "userclass", pathPattern: "/oapi/OAStu/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学生信息(21 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 12, targetCode: "userclass", pathPattern: "/oapi/TeachInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:任教信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 13, targetCode: "userclass", pathPattern: "/oapi/OATea/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:教师信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 14, targetCode: "userclass", pathPattern: "/oapi/UserInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:用户信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 15, targetCode: "userclass", pathPattern: "/oapi/VSchClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:虚拟班级(17 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 16, targetCode: "userclass", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:组织(4 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 17, targetCode: "userclass", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:用户(5 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 18, targetCode: "yschcms", pathPattern: "/oapi/site/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户(注册名 yschcms)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 19, targetCode: "svc-hall", pathPattern: "/oapi/svc/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "事项:items/filters/detail/view/attachments/related/snapshot", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 20, targetCode: "flow-center", pathPattern: "/oapi/flow/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "流程:list/getFlow/apply/instance/progress/diagram/withdraw/callback-reg", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 21, targetCode: "usermsg", pathPattern: "/oapi/msg/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "消息:sendUserMsg/list/unreadCount/read/readAll + todo(inc/reset/list) + ws/ticket", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
{ id: 22, targetCode: "notice", pathPattern: "/oapi/notice/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "字典:getDict", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
// ---- 治理后补充样本(豁免/差异化旋钮/停用/空备注)----
{ id: 23, targetCode: "yschcms", pathPattern: "/oapi/site/render/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 600, cacheScope: "GLOBAL", rateLimitQps: 50, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户站点渲染对外只读:豁免签名 + 全局缓存 10 分钟", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
{ id: 24, targetCode: "svc-hall", pathPattern: "/oapi/svc/items/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 60, cacheScope: "CALLER", rateLimitQps: 20, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "办事大厅事项对外查询:豁免签名,按调用方隔离缓存", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
{ id: 25, targetCode: "notice", pathPattern: "/oapi/notice/public/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 300, cacheScope: "GLOBAL", rateLimitQps: 100, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "通知公告对外只读镜像(旧门户拉取)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
{ id: 26, targetCode: "userbase", pathPattern: "/oapi/user/batchAdd", httpMethod: "*", visibility: "EXTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 5, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "批量建号对外(密钥强校验,低 qps 熔断)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
{ id: 27, targetCode: "usermsg", pathPattern: "/oapi/msg/health", httpMethod: "*", visibility: "INTERNAL", authMode: "ANON", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 10000, requestBodyLimitMb: 1, enabled: true, remark: "", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
{ id: 28, targetCode: "flow-center", pathPattern: "/oapi/flow/diagram/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 60000, requestBodyLimitMb: 10, enabled: false, remark: "流程图渲染内测:暂关", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
];
/** 实测调用方 appKey(用于台账 callerAppKey 样本与筛选演示) */
const APP_KEYS = ["100001", "100007", "100009", "100010", "100013"] as const;
/** 台账时间跨度:以种子重置时刻为终点,向过去铺 6.5 天 */
const LOG_SPAN_DAYS = 6.5;
const LOG_COUNT = 233;
/** 确定性伪随机(LCG):同一次进程内种子稳定,刷新/重启后日志可复现同分布 */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** 台账种子:按路由表推导 path 形态;覆盖 200/401/403/404/500、anonymous、无钥(null) 401、快慢耗时 */
function buildSeedLogs(routes: OpenApiRoute[], now: number): OpenApiLog[] {
const rnd = lcg(20261001);
const byTarget = new Map<string, { path: string; anon: boolean }[]>();
for (const r of routes) {
const arr = byTarget.get(r.targetCode) ?? [];
arr.push({ path: r.pathPattern, anon: r.authMode === "ANON" });
byTarget.set(r.targetCode, arr);
}
const targetCodes = [...byTarget.keys()];
const weight: Record<string, number> = {
userbase: 0.22, userclass: 0.14, yschcms: 0.06, "svc-hall": 0.14,
"flow-center": 0.12, usermsg: 0.14, notice: 0.18,
};
const pick = <T,>(arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)];
const pickTarget = (): string => {
let r = rnd();
for (const t of targetCodes) {
r -= weight[t] ?? 0;
if (r < 0) return t;
}
return targetCodes[targetCodes.length - 1];
};
const logs: OpenApiLog[] = [];
for (let i = 0; i < LOG_COUNT; i++) {
const ts = now - i * ((LOG_SPAN_DAYS * 24 * 3600 * 1000) / LOG_COUNT) - rnd() * 3600 * 1000;
const targetCode = pickTarget();
const rt = pick(byTarget.get(targetCode) as { path: string; anon: boolean }[]);
let path = rt.path;
if (rnd() < 0.45 && path.endsWith("/**")) {
path = path.slice(0, -3) + "/" + String(Math.floor(rnd() * 900) + 100);
}
const method = rnd() < 0.7 ? "GET" : "POST";
let callerAppKey: string | null;
let status: number;
const cr = rnd();
if (cr < 0.6) {
// 持钥调用:多数放行,少量鉴权/限流/NotFound/后端炸
callerAppKey = pick(APP_KEYS);
const sr = rnd();
status = sr < 0.78 ? 200 : sr < 0.84 ? 401 : sr < 0.9 ? 403 : sr < 0.95 ? 404 : 500;
} else if (cr < 0.8) {
// 匿名调用(后端记字符串 "anonymous"):命中豁免(ANON)路由才放行
callerAppKey = "anonymous";
const sr = rnd();
status = rt.anon ? (sr < 0.85 ? 200 : 500) : sr < 0.7 ? 401 : 403;
} else {
// 未携带 appKey:网关层直接拒(callerAppKey=null 的 401 行)
callerAppKey = null;
status = rnd() < 0.85 ? 401 : 403;
}
const costMs = status < 400 ? Math.floor(3 + Math.pow(rnd(), 2) * 1600) : Math.floor(1 + rnd() * 25);
logs.push({
id: 50000 + (LOG_COUNT - i),
ts: new Date(ts).toISOString(),
callerAppKey,
targetCode,
method,
path,
status,
backendCode: null,
costMs,
});
}
return logs;
}
/* ============================ 内存态(PUT 可变,刷新重置) ============================ */
let routes: OpenApiRoute[] = [];
let targets: OpenApiTarget[] = [];
let logs: OpenApiLog[] = [];
/** 重置为静态种子(vite 中间件在整页文档请求时调用 → 浏览器刷新即还原) */
export function resetGatewayMockState(): void {
routes = SEED_ROUTES.map((r) => ({ ...r }));
targets = SEED_TARGETS.map((t) => ({ ...t }));
logs = buildSeedLogs(SEED_ROUTES, Date.now());
}
resetGatewayMockState();
/* ============================ 工具 ============================ */
function ok<T>(data: T): { code: number; msg: string; data: T } {
return { code: 0, msg: "", data };
}
function bizError(msg: string): { code: number; msg: string; data: null } {
return { code: 2, msg, data: null };
}
function paginate<T>(list: T[], pageIndex: number, pageSize: number): PageResult<T> {
const total = list.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return {
total,
content: list.slice((pageIndex - 1) * pageSize, pageIndex * pageSize),
pageIndex,
pageSize,
totalPages,
hasNext: pageIndex < totalPages,
};
}
/** 查询参数取正整数:缺省/非法回落 def,上限 cap(logs pageSize 契约:默认 50、上限 500) */
function positiveInt(raw: string | null, def: number, cap: number): number {
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) return def;
return Math.min(n, cap);
}
function enumErrMsg(field: string, val: unknown, legal: readonly string[]): string {
return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(合法 [${legal.join(", ")}])`;
}
function intErrMsg(field: string, val: unknown): string {
return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(须为 ≥0 的整数)`;
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function send(res: ServerResponse, payload: unknown): void {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
res.end(JSON.stringify(payload));
}
/* ============================ 各端点实现 ============================ */
function listRoutes(q: URLSearchParams): PageResult<OpenApiRoute> {
const sorted = [...routes].sort(
(a, b) => a.targetCode.localeCompare(b.targetCode) || a.pathPattern.localeCompare(b.pathPattern)
);
return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500));
}
function listTargets(q: URLSearchParams): PageResult<OpenApiTarget> {
const sorted = [...targets].sort((a, b) => a.targetCode.localeCompare(b.targetCode));
return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500));
}
function listLogs(q: URLSearchParams): PageResult<OpenApiLog> {
const targetCode = (q.get("targetCode") ?? "").trim();
const callerAppKey = (q.get("callerAppKey") ?? "").trim();
// 筛选为精确等值(空串=不过滤),先过滤再分页,按 id 倒序(最新在前)
let rows = logs;
if (targetCode) rows = rows.filter((l) => l.targetCode === targetCode);
if (callerAppKey) rows = rows.filter((l) => l.callerAppKey === callerAppKey);
rows = [...rows].sort((a, b) => b.id - a.id);
return paginate(rows, positiveInt(q.get("pageIndex"), 1, 100000), positiveInt(q.get("pageSize"), 50, 500));
}
const VISIBILITIES = ["EXTERNAL", "INTERNAL"] as const;
const AUTH_MODES = ["SIGNED", "ANON"] as const;
const CACHE_SCOPES = ["CALLER", "GLOBAL"] as const;
/** PUT /routes/{id}:只认 7 个治理键;校验语义对齐后端(含 enabled 非真布尔按 false 落库的坑) */
function updateRoute(id: number, body: any): { code: number; msg: string; data: any } {
const row = routes.find((r) => r.id === id);
if (!row) return bizError(`路由不存在:id=${id}`);
if (!VISIBILITIES.includes(body?.visibility)) return bizError(enumErrMsg("visibility", body?.visibility, VISIBILITIES));
if (!AUTH_MODES.includes(body?.authMode)) return bizError(enumErrMsg("authMode", body?.authMode, AUTH_MODES));
if (!CACHE_SCOPES.includes(body?.cacheScope)) return bizError(enumErrMsg("cacheScope", body?.cacheScope, CACHE_SCOPES));
const ttl = body?.cacheTtlSeconds;
if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0) return bizError(intErrMsg("cacheTtlSeconds", ttl));
const qps = body?.rateLimitQps;
if (typeof qps !== "number" || !Number.isInteger(qps) || qps < 0) return bizError(intErrMsg("rateLimitQps", qps));
row.visibility = body.visibility;
row.authMode = body.authMode;
row.cacheScope = body.cacheScope;
row.cacheTtlSeconds = ttl;
row.rateLimitQps = qps;
// enabled 必须真布尔:传字符串 "true" 一律按 false 处理(后端真实行为,勿修)
row.enabled = body.enabled === true;
row.remark = body.remark == null ? "" : String(body.remark);
row.gmtModified = new Date().toISOString();
return ok({ loaded: routes.length });
}
/** PUT /targets/{id}:baseUrl 非空 + enabled 真布尔(同款坑) */
function updateTarget(id: number, body: any): { code: number; msg: string; data: any } {
const row = targets.find((t) => t.id === id);
if (!row) return bizError(`目标不存在:id=${id}`);
const baseUrl = body?.baseUrl;
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
return bizError(`baseUrl 非法:${baseUrl === undefined ? "(缺失)" : String(baseUrl)}(须为非空 url)`);
}
row.baseUrl = baseUrl.trim();
row.enabled = body.enabled === true;
row.gmtModified = new Date().toISOString();
return ok({ targets: targets.length });
}
/* ============================ 代理测试(服务端签名回环的真实语义 mock) ============================ */
const MOCK_PROXY_SECRET = "mock-secret-0123456789";
/** 复刻后端 SignVerifyFilter.sortRawQuery:逐对解码、k=v 字典序、& 连接 */
function sortRawQuery(rawQuery: string | null): string {
if (!rawQuery) return "";
const pairs: string[] = [];
for (const part of rawQuery.split("&")) {
if (!part) continue;
const idx = part.indexOf("=");
const k = idx < 0 ? part : part.substring(0, idx);
const v = idx < 0 ? "" : part.substring(idx + 1);
pairs.push(urlDecode(k) + "=" + urlDecode(v));
}
pairs.sort();
return pairs.join("&");
}
/** 复刻后端 URLDecoder.decode(UTF-8):+ 号解为空格;非法 %xx 转义回退原串 */
function urlDecode(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, "%20"));
} catch {
return s;
}
}
/**
* 代理测试 mock:错误文案与后端 ProxyTestController 逐字一致,data 形状与真实响应同构。
* 校验顺序照后端真实链路:url/appKey → 容忍解析 → 密钥解析(fail-closed)→ 签名 → 路由命中。
*/
function mockProxyTest(body: any): { code: number; msg: string; data: any } {
const method = String(body?.method ?? "GET").trim().toUpperCase() || "GET";
const url = typeof body?.url === "string" ? body.url.trim() : "";
const appKey = typeof body?.appKey === "string" ? body.appKey.trim() : "";
const appSecret = typeof body?.appSecret === "string" ? body.appSecret.trim() : "";
if (!url) return bizError("url 不可为空(形如 http://网关域名/{target}/oapi/**)");
if (!appKey) return bizError("appKey 不可为空");
// 容忍解析:剥 scheme,取 path+query(回环只打网关自身,scheme/host 无意义)
let pathAndQuery: string;
const schemeEnd = url.indexOf("://");
if (schemeEnd >= 0) {
const pathStart = url.indexOf("/", schemeEnd + 3);
if (pathStart < 0) return bizError(`URL 缺少路径(形如 http://host/{target}/oapi/**):${url}`);
pathAndQuery = url.substring(pathStart);
} else if (url.startsWith("/")) {
pathAndQuery = url;
} else {
pathAndQuery = "/" + url;
}
const qIdx = pathAndQuery.indexOf("?");
const path = qIdx < 0 ? pathAndQuery : pathAndQuery.substring(0, qIdx);
const query = qIdx < 0 ? null : pathAndQuery.substring(qIdx + 1);
if (!path || path === "/") {
return bizError("路径须形如 /{target}/oapi/**(如 /userbase/oapi/user/byId)");
}
const second = path.indexOf("/", 1);
if (second < 0) {
return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`);
}
const target = path.substring(1, second);
const rest = path.substring(second);
if (!target || target.includes("..")) {
return bizError(`target 非法:${target}`);
}
if (rest !== "/oapi" && !rest.startsWith("/oapi/")) {
return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`);
}
const stripped = rest;
// 密钥解析(回环前,fail-closed——顺序与后端一致)
let secret: string;
let secretSource: "INPUT" | "REDIS";
if (appSecret) {
secret = appSecret;
secretSource = "INPUT";
} else if ((APP_KEYS as readonly string[]).includes(appKey)) {
secret = MOCK_PROXY_SECRET;
secretSource = "REDIS";
} else {
return bizError("appSecret 未填且网关解析不到该 appKey 的密钥(未注册/已禁用/密钥链不可用)");
}
if (secret.length < 16) {
return bizError("签名失败:签名密钥长度不能小于16位");
}
// 签名(复刻 SignUtil.signRequest + buildCanonicalTarget:HMAC-SHA256 → base64)
const sorted = sortRawQuery(query);
const canonical = sorted ? `${stripped}?${sorted}` : stripped;
const timestamp = String(Date.now());
const signature = createHmac("sha256", secret).update(`${method}\n${canonical}\n${timestamp}`).digest("base64");
// 路由命中:targetCode 相等 + pathPattern(剥 /** 后)等于 stripped 或为其路径前缀
const hit = routes.find((r) => {
if (r.targetCode !== target) return false;
const prefix = r.pathPattern.replace(/\/\*\*$/, "");
return stripped === prefix || stripped.startsWith(prefix + "/");
});
const base = {
target,
method,
requestUrl: "http://mock-gateway" + pathAndQuery,
latencyMs: 20 + Math.floor(Math.random() * 281),
bodyTruncated: false,
signature: { appKey, timestamp, canonical, signature, secretSource },
error: null,
};
if (!hit) {
// 模拟网关裸 404
return ok({ ...base, status: 404, contentType: null, body: "" });
}
const echoBody = JSON.stringify({
code: 0,
msg: "mock 应答",
data: {
mock: true,
route: { pathPattern: hit.pathPattern, visibility: hit.visibility, authMode: hit.authMode },
echo: { method, url },
},
});
return ok({ ...base, status: 200, contentType: "application/json", body: echoBody });
}
/* ============================ 中间件入口(vite dev server 内) ============================ */
/**
* 拦截带 serviceHost 前缀(/open-gateway)的治理面请求。命中并应答返回 true,
* 未命中返回 false(交回 vite 内部中间件/代理,非 mock 模式根本不会注册本处理)。
*/
export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://mock.local");
const pathname = url.pathname;
// 超管门槛探测(src/utils/superGate.ts):dev:mock 离线可演示两态——
// 默认超管放行;起服务前设 YH_MOCK_SUPER=0 演示非超管拦截态。
if (pathname === "/userbase/base/User/CurrentUser") {
const superAdmin = process.env.YH_MOCK_SUPER !== "0";
send(res, ok({ isSuperAdmin: superAdmin, name: superAdmin ? "mock超级管理员" : "mock普通账号" }));
return true;
}
// 外观三键同源读(对接规范 §3;线上=网关 AppearanceProxyController 透传 userhome):
// 【裸 payload 无信封】——init 见 code 字段会当异常形态跳过收敛;
// theme/avatar 给生效值即离线走查三键收敛与 yh-appearance-updated 事件。
if (pathname === "/open-gateway/api/appearance") {
send(res, {
theme: { theme: "blue", sideDeep: false, gradOn: true, sideFollow: true },
avatar: "text",
logoUrl: null,
});
return true;
}
if (!pathname.startsWith("/open-gateway/api/")) return false;
const api = pathname.slice("/open-gateway".length);
const method = (req.method ?? "GET").toUpperCase();
const q = url.searchParams;
if (api === "/api/internal/admin/routes" && method === "GET") {
send(res, ok(listRoutes(q)));
return true;
}
if (api === "/api/internal/admin/targets" && method === "GET") {
send(res, ok(listTargets(q)));
return true;
}
if (api === "/api/internal/admin/logs" && method === "GET") {
send(res, ok(listLogs(q)));
return true;
}
if (api === "/api/internal/admin/routes/reload" && method === "POST") {
send(res, ok({ loaded: routes.length, targets: targets.length }));
return true;
}
if (api === "/api/internal/admin/session" && method === "GET") {
// ssoHost 留空:mock 没有真 SSO,前端据此隐藏「退出登录」(同 Notice 的 ssoHost 空态行为)
send(res, ok({ ssoHost: "", principal: "mock 治理" }));
return true;
}
if (api === "/api/internal/admin/proxy-test" && method === "POST") {
const body = await readJsonBody(req);
if (body === undefined) {
send(res, bizError("请求体不是合法 JSON"));
return true;
}
send(res, mockProxyTest(body));
return true;
}
let m = /^\/api\/internal\/admin\/routes\/(\d+)$/.exec(api);
if (m) {
if (method !== "PUT") {
send(res, bizError(`不支持的请求:${method} ${api}`));
return true;
}
const body = await readJsonBody(req);
if (body === undefined) {
send(res, bizError("请求体不是合法 JSON"));
return true;
}
send(res, updateRoute(Number(m[1]), body));
return true;
}
m = /^\/api\/internal\/admin\/targets\/(\d+)$/.exec(api);
if (m) {
if (method !== "PUT") {
send(res, bizError(`不支持的请求:${method} ${api}`));
return true;
}
const body = await readJsonBody(req);
if (body === undefined) {
send(res, bizError("请求体不是合法 JSON"));
return true;
}
send(res, updateTarget(Number(m[1]), body));
return true;
}
return false;
}
/** 读 PUT 的 JSON 请求体;空体按 {},解析失败返回 undefined(由调用方回业务错) */
async function readJsonBody(req: IncomingMessage): Promise<any | undefined> {
const raw = await readBody(req);
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
+17
View File
@@ -0,0 +1,17 @@
*{
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,body,#app{
height: 100%;
}
body{
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
min-width: 1200px;
}
a{
text-decoration: none;
}
+2
View File
@@ -0,0 +1,2 @@
/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 License: none (public domain)*/
div, span, applet, object, iframe,h1, h2, h3, h4, h5, h6, p, blockquote, pre,a, abbr, acronym, address, big, cite, code,del, dfn, em, img, ins, kbd, q, s, samp,small, strike, strong, sub, sup, tt, var,b, u, i, center,dl, dt, dd, ol, ul, li,fieldset, form, label, legend,table, caption, tbody, tfoot, thead, tr, th, td,article, aside, canvas, details, embed,figure, figcaption, footer, header, hgroup,menu, nav, output, ruby, section, summary,time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; vertical-align: baseline;}/* HTML5 display-role reset for older browsers */article, aside, details, figcaption, figure,footer, header, hgroup, menu, nav, section { display: block;}ol, ul { list-style: none;}blockquote, q { quotes: none;}blockquote:before, blockquote:after,q:before, q:after { content: ''; content: none;}table { border-collapse: collapse; border-spacing: 0;}
+84
View File
@@ -0,0 +1,84 @@
/*滚动条样式 */
/*第一种样式,所有的滚动条,都默认这个样子*/
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/*正常情况下滑块的样式*/
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .05);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
*:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在滑块上时滑块的样式*/
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, .3);
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*正常时候的主干部分*/
*::-webkit-scrollbar-track {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: white;
}
/*鼠标悬浮在滚动条上的主干部分*/
*::-webkit-scrollbar-track:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
background-color: rgba(0, 0, 0, .01);
}
/*第一种样式结束*/
/*第二种样式*/
.scroll2::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/*正常时候的主干部分*/
.scroll2::-webkit-scrollbar-track {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: white;
}
/*正常情况下滑块的样式*/
.scroll2::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .05);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在该类指向的控件上时滑块的样式*/
.scroll2:hover::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, .2);
border-radius: 10px;
-webkit-box-shadow: inset 1px 1px 0 rgba(0, 0, 0, .1);
}
/*鼠标悬浮在滚动条上的主干部分*/
.scroll2::-webkit-scrollbar-track:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
}
/*鼠标悬浮在滑块上时滑块的样式*/
.scroll2::-webkit-scrollbar-thumb:hover {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .4);
}
/*第二种样式结束*/
@@ -0,0 +1,35 @@
.my-table {
width: 100%;
border-bottom: 1px solid #c7c7c7;
border-right: 1px solid #c7c7c7;
border-collapse: separate;
.td-title {
background: #fafafa;
vertical-align: middle;
color: #111;
font-weight: 550;
}
th {
background: #fafafa;
height: 20px;
line-height: 20px;
padding: 12px;
}
td {
height: 20px;
line-height: 20px;
padding: 12px;
}
th,
td {
border-top: 1px solid #c7c7c7;
border-left: 1px solid #c7c7c7;
margin-left: -1px;
margin-top: -1px;
text-align: center;
}
}
@@ -0,0 +1,59 @@
.rule-tree {
padding: 0 !important;
width: 280px !important;
.head-ul {
background: #f6f8f9;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 48px;
li {
width: 34px;
height: 34px;
box-sizing: content-box;
border-radius: 50%;
position: relative;
cursor: pointer;
.ivu-tooltip,
.ivu-tooltip-rel {
width: 100%;
height: 100%;
display: block;
}
svg {
position: absolute;
inset: 0;
margin: auto;
}
&:hover {
background-color: #eaeaea;
cursor: pointer;
}
&.disabled {
background-color: #fff;
cursor: not-allowed;
}
button {
display: inline-block;
width: 100%;
height: 100%;
border-radius: 50%;
color: #515a6e;
border: 0;
outline: none;
background-color: transparent;
cursor: inherit;
}
}
}
.body-box {
padding: 11px 0;
}
.ztree li span.button {
background-repeat: 10px;
}
}
@@ -0,0 +1,26 @@
table.small-table {
border: 1px solid #ebeef5;
border-radius: 5px;
th {
background-color: #fafafa;
border-bottom: 1px solid #ebeef5;
padding: 10px 20px;
}
td {
padding: 5px 20px;
}
tr:first-child td {
padding-top: 10px;
}
tr:last-child td {
padding-bottom: 10px;
}
.input-box {
width: 100px;
vertical-align: text-bottom;
display: inline-block;
}
.ivu-input-wrapper {
vertical-align: text-bottom;
}
}
+8
View File
@@ -0,0 +1,8 @@
$liColor: #969799, #e6a23c, #45b972, #eb0404;
@each $c in $liColor {
$i: index($liColor, $c);
.status-color-#{$i} {
color: $c;
}
}
+135
View File
@@ -0,0 +1,135 @@
@import "base/base";
@import "base/bscroll.css";
@import "base/reset";
@import "utils/mixins";
@import "utils/utils";
@import "componets/status";
@import "componets/small_table";
@import "componets/my_table";
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.base-subtitle {
position: relative;
padding-left: 12px;
line-height: 24px;
font-size: 15px;
font-weight: 400;
&::before {
content: "";
display: block;
width: 3px;
background: #5979ea;
height: 15px;
border-radius: 4px;
position: absolute;
top: 5px;
left: 0;
}
}
h4.second-title {
@extend .base-subtitle;
&::before {
background: #5979ea;
}
}
h4.subtitle {
@extend .second-title;
&::before {
background: #bda366;
}
}
.details-wrap {
display: flex;
padding-top: 10px;
.left-box {
width: 200px;
height: 100%;
.user-list-ul {
height: calc(100% - 50px);
overflow-y: auto;
li {
height: 42px;
border: 1px solid #ebeef5;
border-top: none;
position: relative;
cursor: pointer;
transition: border 200ms;
img {
object-fit: cover;
width: 30px;
height: 30px;
border-radius: 3px;
}
span.stu-name {
height: 20px;
line-height: 20px;
display: inline-block;
width: 60px;
}
span.stu-card {
width: 50px;
text-align: right;
}
}
li.active {
// background: #f3f3f3;
color: #7892ed;
border-right: 3px solid #5979ea;
}
}
}
.right-box {
width: 100%;
padding: 0 15px;
}
}
.table-spin-load {
width: 60px;
height: 90px;
padding-top: 60px;
background-image: url(../image/loading.gif);
background-size: 60px 60px;
background-position: top center;
background-repeat: no-repeat;
border: none;
color: #577afb;
margin: 0 auto;
}
.ivu-table-cell-tree {
margin-right: 4px;
margin-bottom: 2px;
}
/* ================= 表格操作列(utils/tableActions.ts)=================
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
.op-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
.operating-link {
padding-right: 0;
white-space: nowrap;
}
}
+13
View File
@@ -0,0 +1,13 @@
@mixin tag_mixin($bgColor,$textColor) {
width:64px;
height:20px ;
padding: 0 5px;
background: $bgColor;
color: $textColor;
font-size: 12px;
border-radius: 2px 2px 2px 2px;
text-align: center;
line-height: 20px;
}
+170
View File
@@ -0,0 +1,170 @@
// utils 工具// 颜色
@import "./varible";
.bg-error {
background-color: $color-error !important;
}
.text-error {
color: $color-error !important;
}
.text-success {
color: $color-success !important;
}
.text-warning {
color: $color-warning !important;
}
.text-primary {
color: $color-primary !important;
}
.text-normal {
color: #404042 !important;
}
.text-black {
color: #000;
}
.text-dark {
color: #333;
}
.text-deep {
color: #555;
}
.text-weak {
color: #b3b3b3;
}
.text-white {
color: #fff;
}
.text-error.disabled {
color: #aaa !important;
}
// 文字对齐
.tl {
text-align: left;
}
.tc {
text-align: center;
}
.tr {
text-align: right;
}
// 浮动与清除浮动
.fl {
float: left;
}
.fr {
float: right;
}
.fix {
*zoom: 1;
}
.fix:after {
display: table;
content: "";
clear: both;
}
// 显示
.dn {
display: none;
}
.di {
display: inline;
}
.db {
display: block;
}
.dib {
display: inline-block;
}
.dt {
display: table;
}
div.dib {
*display: inline;
*zoom: 1;
}
.vm {
vertical-align: middle;
}
.vib {
display: inline-block;
vertical-align: middle;
}
.ovh {
overflow: hidden;
}
// 定位
.pr {
position: relative;
}
.pa {
position: absolute;
}
.pf {
position: fixed;
}
.align-middle {
position: relative;
top: 50%;
transform: translateY(-50%);
}
.align-middle-wrap {
display: flex;
align-items: center;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@for $i from 1 through 100 {
.width-#{$i} {
width: $i * 1%;
}
.height-#{$i} {
height: $i * 1px;
}
// @if $i<50 and $i%2==0 {
// @if $i<50 {
.ml-#{$i} {
margin-left: $i * 1px !important;
}
.mr-#{$i} {
margin-right: $i * 1px !important;
}
.mt-#{$i} {
margin-top: $i * 1px !important;
}
.mb-#{$i} {
margin-bottom: $i * 1px !important;
}
// }
}
// 字体
@for $i from 10 through 28 {
.f#{$i} {
font-size: $i * 1px;
}
}
// .chart-box {
// height: 50px;
// width: 100%;
// border-bottom: 50px solid;
// border-image: linear-gradient(90deg, #f55c65 0%, #fcced0 48%, #d4dffc 52%, #7194f5 100%) 50 50 50 50;
// }
// @for $i from 0 through 50 {
// .blue-#{$i} {
// background-color: rgba(#7194f5, 0.1 * $i * 0.2);
// }
// }
// @for $i from 0 through 50 {
// .red-#{$i} {
// background-color: rgba(#f55c65, 0.1 * $i * 0.2);
// }
// }
+22
View File
@@ -0,0 +1,22 @@
/*scss变量*/
// 主题色
$color-error: #e8684a;
$color-success: #45b972;
$color-warning: #e9b954;
$color-primary: #5979ea;
// 文字色
$color-black: #404042;
$color-dark: #333;
$color-deep: #555;
$color-pl: #999999;
$color-weak: #b3b3b3;
$color-white: #fff;
// 背景色
$bg-bar: #f9f9f9;
$bg-page: #f3f3f3;
$bg-page-light: #f9f9f9;
$border-color: #e9e9e9;
+94
View File
@@ -0,0 +1,94 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const $$userConfigMap: typeof import('./api/index')['$$userConfigMap']
const Button: typeof import('view-ui-plus')['Button']
const EffectScope: typeof import('vue')['EffectScope']
const Form: typeof import('view-ui-plus')['Form']
const FormItem: typeof import('view-ui-plus')['FormItem']
const Input: typeof import('view-ui-plus')['Input']
const Message: typeof import('view-ui-plus')['Message']
const Modal: typeof import('view-ui-plus')['Modal']
const Select: typeof import('view-ui-plus')['Select']
const Table: typeof import('view-ui-plus')['Table']
const api: typeof import('./api/index')['default']
const apiDefinitions: typeof import('./api/apiDefinitions')['default']
const computed: typeof import('vue')['computed']
const createApis: typeof import('./api/createApis')['createApis']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const mountApis: typeof import('./api/createApis')['mountApis']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useForm: typeof import('alova/client')['useForm']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const usePagination: typeof import('alova/client')['usePagination']
const useRequest: typeof import('alova/client')['useRequest']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useWatcher: typeof import('alova/client')['useWatcher']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const withConfigType: typeof import('./api/createApis')['withConfigType']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+76
View File
@@ -0,0 +1,76 @@
<template>
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
<Alert type="warning" class="mb-1" show-icon>
<template #icon>
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
</template>
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
<template #desc v-if="info" slot="desc">{{ info }}</template>
</Alert>
<template #footer>
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { Modal, Button, Alert, Icon } from "view-ui-plus";
const isModal = ref(false),
okLoading = ref(false),
cancleLoading = ref(false);
const props = withDefaults(
defineProps<{
title?: string;
msg: string;
info?: string;
isLoading?: boolean;
confirmText?: string;
closeText?: string;
confirmFn: any;
closeFn: any;
destroy: Function;
}>(),
{
title: "提示",
isLoading: true,
confirmText: "确认",
closeText: "取消",
}
);
onMounted(function () {
setTimeout(() => {
isModal.value = true;
}, 0);
});
const onOkClick = async () => {
if (!!props.isLoading) okLoading.value = true;
const { onOk, reslove } = props.confirmFn;
await Promise.resolve(onOk());
isModal.value = false;
okLoading.value = false;
reslove(true);
};
const onCancleClick = async () => {
if (!!props.isLoading) cancleLoading.value = true;
const { onCancel, reslove } = props.closeFn;
if (typeof onCancel !== "undefined") await Promise.resolve(onCancel());
cancleLoading.value = false;
isModal.value = false;
reslove(false);
};
const showChange = (state: boolean) => {
if (!state) {
props.destroy();
}
};
</script>
<style></style>
+42
View File
@@ -0,0 +1,42 @@
import { MyConfirm } from "@/types/Basics";
import confirm from "./Confirm.vue";
import { createApp } from "vue";
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
return new Promise<boolean>((reslove) => {
const instance = createApp(confirm, {
title,
msg,
info,
confirmText,
closeText,
isLoading,
confirmFn: {
onOk,
reslove,
},
closeFn: {
onCancel,
reslove,
},
destroy: () => {
closeUnmount();
},
});
// 创建一个节点
const vnode = document.createElement("div");
// 添加到body元素上
document.body.appendChild(vnode);
// 实例挂载到节点上
instance.mount(vnode);
// 关闭状态
// 注册实例并移除节点
function closeUnmount() {
setTimeout(() => {
instance.unmount();
document.body.removeChild(vnode);
}, 100);
}
});
};
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref, computed, withDefaults } from "vue";
interface Props {
prefix?: string;
name?: string;
color?: string;
hoverColor?: string;
size?: string | number;
}
const props = withDefaults(defineProps<Props>(), {
prefix: "icon",
name: "",
color: "#000",
});
const isHover = ref(false);
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
const svgStyle = computed(() => {
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
else return null;
});
const compColor = computed(() => {
if (!props.hoverColor) return props.color;
else {
return !!isHover.value ? props.hoverColor : props.color;
}
});
</script>
<template>
<svg
@mouseover="isHover = true"
@mouseout="isHover = false"
aria-hidden="true"
:color="compColor"
:style="svgStyle"
>
<use :xlink:href="symbolId" :fill="compColor" :color="compColor" />
</svg>
</template>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
@on-page-size-change="onSizeChange" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, useSlots, watch } from 'vue'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
dataApi: any
columns: any[]
tableHeight?: number
autoLoad?: boolean
dataFilter?: (resp: any) => any[]
formatResponse?: (res: any) => { total: number; list: any[] }
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
formatResponse: (res: any) => ({ total: res.total, list: res.content })
})
const emit = defineEmits<{
(e: 'selection-change', list: number[]): void
}>()
const slots = useSlots()
const regionRef = ref<HTMLElement | null>(null)
const measuredH = useElementHeight(regionRef)
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([])
function selectChange(rows: any[]) {
selectList.value = rows.map((r: any) => r.id)
emit('selection-change', selectList.value)
}
const {
loading,
data: resp,
send: sendLoad,
page,
pageSize,
total,
reload
} = usePagination(
(current: number, size: number) =>
props.dataApi({ pageIndex: current, pageSize: size }),
{
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad,
}
)
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => {
pageSize.value = s
localStorage.setItem('customPageCount', String(s))
}
function refreshData() {
page.value = 1
reload()
}
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
let p = page.value
const s = pageSize.value
if (type === OperatorEnum.add && p * s === total.value) p += 1
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
if (type === OperatorEnum.sel) p = 1
if (p < 1) p = 1
page.value = p
sendLoad()
}
defineExpose({ list, refreshData, reloadData })
</script>
<style scoped></style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { LayoutData } from "@/types/Basics";
import Utils from "@/utils";
import config from "@/assets/config/host.config";
import defaultLogo from "@/assets/image/yunhai-logo.svg";
import UserAvatar from "@/components/UserAvatar.vue";
const props = defineProps<{ layoutData: LayoutData; userName?: string }>();
const route = useRoute();
const modList = computed(() => props.layoutData.modList || []);
const isActive = (m: any) => route.path === m.page;
/** 面包屑末段:当前路由 meta.title(三个治理页都带) */
const currTitle = computed(() => (route.meta as any)?.title || "");
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
const isMock = computed(() => !!config.isMock);
/** 顶栏操作者:超管门槛取 CurrentUser.name,取不到(mock/error 态)兜底「网关治理」 */
const opName = computed(() => props.userName || "网关治理");
// 顶栏 Logo(外观三键 §3):localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)
// 优先,本仓资产兜底——绝无空 src/裂图。刷新:mounted 读一次 + 监听
// yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。照 Notice layout 同款。
const logoUrl = ref<string>(defaultLogo);
const readLogoUrl = () => {
let v: string | null = null;
try {
v = localStorage.getItem("yh-logo-url");
} catch {
v = null;
}
logoUrl.value = v || defaultLogo;
};
const onLogoError = () => {
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
};
/* 重载注册表(POST /api/internal/admin/routes/reload):治理面入口,与机器面
/api/internal/routes/refresh 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
const reloading = ref(false);
const lastReloadTime = ref("");
async function reloadRegistry() {
reloading.value = true;
try {
const d: any = await Apis.GatewayAdmin.reload({});
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
} finally {
reloading.value = false;
}
}
/* SSO 基址:网关后端无 env-config 端点(conf.ssoHost 恒空),改从已登录会话里取。
取不到(未配 CAS / mock)则不渲染登出入口——与 Notice 的 ssoHost 空态一致。 */
const ssoHost = ref("");
onMounted(async () => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
try {
const d: any = await Apis.GatewayAdmin.session({});
ssoHost.value = d?.ssoHost || "";
} catch (e: any) {
// 未登录时这条会被 alova 整页跳去 SSO;其它失败只是没有登出入口,不额外弹
}
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
function logout() {
window.location.href = ssoHost.value + "/logout?service=" + window.location.href;
}
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
const reloadPage = inject("reload", () => {}) as () => void;
</script>
<template>
<div class="layout-wrap">
<header class="header-container fix">
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge);
外观三键 §3:平台键 yh-logo-url 优先、本仓资产兜底,加载失败回本仓资产 -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
<div class="school-bg"></div>
<div class="header-tools fr">
<span v-if="isMock" class="mock-badge" title="当前为本地 mock 数据,重启 dev:mock 后还原">MOCK 数据</span>
<span v-if="lastReloadTime" class="last-reload">最后更新 {{ lastReloadTime }}</span>
<Button size="small" class="reload-btn" @click="reloadPage">手动刷新</Button>
<Button type="primary" size="small" class="reload-btn" :loading="reloading" @click="reloadRegistry">
重载注册表
</Button>
</div>
<div class="header-aside fr">
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
</div>
</header>
<div class="left-menu">
<div class="menu-top-box"></div>
<ul>
<li v-for="m in modList" :key="m.page" :class="{ active: isActive(m) }">
<router-link :to="m.page">{{ m.title }}</router-link>
</li>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav">
<Breadcrumb separator=">">
<BreadcrumbItem>
<span class="crumb-home">
<i class="iconfont icon-shouye"></i>
首页
</span>
</BreadcrumbItem>
<BreadcrumbItem>{{ layoutData.appName }}</BreadcrumbItem>
<BreadcrumbItem v-if="currTitle">{{ currTitle }}</BreadcrumbItem>
</Breadcrumb>
</div>
</div>
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
.header-container .logo-badge {
margin: 0 10px 0 0;
}
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
.crumb-home .iconfont {
position: static;
top: 0;
}
/* 顶栏操作区(重载按钮 + mock 徽标):皮肤把 header 改 flex 垂直居中,只留间距 */
.header-tools {
display: flex;
align-items: center;
gap: 10px;
margin-right: 12px;
}
.header-tools .reload-btn {
margin: 0;
}
.header-tools .last-reload {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.header-tools .mock-badge {
font-size: 12px;
line-height: 1;
padding: 5px 10px;
border-radius: 999px;
background: rgba(255, 153, 0, 0.22);
border: 1px solid rgba(255, 153, 0, 0.65);
color: #ffd49a;
}
/* 登出入口:贴用户胶囊右侧,沿用顶栏次要文字色(不做按钮,避免与「重载注册表」抢视觉权重) */
.header-aside .logout {
margin-left: 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import utils from "@/utils";
// 接口层副作用:执行 mountApis 把 Apis 挂到 globalThis(页面以全局 Apis.GatewayAdmin.* 调用)。
// auto-import 只登记 default 名 api,页面引用的是 Apis,不会注入 import——必须在此显式引入。
import "@/api";
// @ts-ignore
import ViewUIPlus from "view-ui-plus";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
// index.html 的 yh-theme-init.js 按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
//(本地 layout.css 快照已随之退役,删防双份漂移)
import "virtual:uno.css";
import "virtual:svg-icons-register";
import config from "@/assets/config/host.config";
import { consumeSsoReturnUrl } from "@/utils/ssoReturn";
/* 外观三键通道契约(§3):yh-theme-init 异步补齐段按「__APP_CONFIG__.serviceHost +
/api/appearance」取服务端真源。网关后端无 env-config 端点、__APP_CONFIG__ 恒空,
这里用 host.config 的同源推导值(生产 /open-gateway、dev 取 VITE_SERVICE_HOST)播种,
否则 init 会裸打域根 /api/appearance 404。模块脚本先于 DOMContentLoaded+delay 执行,时序安全。 */
const g = window as any;
g.__APP_CONFIG__ = g.__APP_CONFIG__ || {};
if (!g.__APP_CONFIG__.serviceHost) g.__APP_CONFIG__.serviceHost = config.serviceHost;
const app = createApp(App);
app.use(router);
app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing);
// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。
const ssoReturn = consumeSsoReturnUrl();
if (ssoReturn) router.replace(ssoReturn);
app.mount("#app");
+9
View File
@@ -0,0 +1,9 @@
import { createRouter, createWebHashHistory } from "vue-router";
import routes from "./routes";
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
+41
View File
@@ -0,0 +1,41 @@
import type { RouteRecordRaw } from "vue-router";
/**
* 网关治理前端路由(三项,无权限矩阵——网关无菜单/角色接口)。
* 与 UserBase 一致用 hash 模式:静态托管下刷新不依赖服务端 rewrite。
*/
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/ledger" },
{
path: "/ledger",
name: "LedgerList",
component: () => import("@/views/ledger/LedgerList.vue"),
meta: { title: "访问台账" },
},
{
path: "/routes",
name: "RouteList",
component: () => import("@/views/route/RouteList.vue"),
meta: { title: "路由注册表" },
},
{
path: "/targets",
name: "TargetList",
component: () => import("@/views/target/TargetList.vue"),
meta: { title: "目标地址" },
},
{
path: "/apps",
name: "AppList",
component: () => import("@/views/app/AppList.vue"),
meta: { title: "应用管理" },
},
{
path: "/proxy-test",
name: "ProxyTest",
component: () => import("@/views/proxytest/ProxyTest.vue"),
meta: { title: "代理测试" },
},
];
export default routes;
+79
View File
@@ -0,0 +1,79 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 公共类型
*/
export declare interface IObj {
[key: string]: any;
}
export declare interface PageParams {
page: !number;
count: !number;
[keys: string]: any; //任意属性
}
export declare interface PageData {
list: T;
total: number;
[keys: string]: any; //任意属性
}
interface RelayConf {
ziyuankuHost: string; //资源库
zhuanxiangHost: string; //专项
}
/* 系统配置 */
export interface BaseConf {
baseHost: string;
ssoHost: string;
serviceHost: string;
relayConf: RelayConf;
isMock: Boolean;
isDebug?: Boolean;
[keys: string]: any; //任意属性
}
/* PC端角色 */
export interface PcDebugUser {
DebugUser: string;
AppType: string;
// yschapirequest: number;
}
/* webapp端角色 */
export declare interface WappDebugUser {
ticket: string;
schoolId: Number;
userId: Number;
roleId: Number;
}
export declare interface CurrentRole {
childId: number | null;
roleId: number;
schId: number;
userId: number;
}
export interface layoutData {
appName: String;
appList: string[];
modList: Array<string>;
userInfo: Object;
ssoHost: String;
baseHost: String;
isAlone: Boolean; //是否只有一个选项卡
}
export interface BaseDict {
k: any;
v: string | null;
[keys: string]: any; //任意属性
}
/* 计算表格高度的参数类型 */
export interface UseTableSetting {
/** 是否有查询面板 */
isSearch: boolean = true;
/** 是否有分页 */
isPage: boolean = true;
/** 是否是tabs布局 */
isTabs: boolean = false;
/** 是否有按钮操作 */
isOperat: boolean = true;
/** 额外的高度 */
otherH: number = 0;
}
/* 布局组件类型 */
export interface LayoutData {
appName: string | null;
appList: Array<any>;
modList: Array<any>;
userInfo: any;
ssoHost: string | null;
baseHost: string | null;
isAlone: boolean;
}
/* 自定义弹出对话框 */
export interface MyConfirm {
title?: string;
msg: string;
info?: string;
isLoading?: boolean;
confirmText?: string;
closeText?: string;
onOk?: Function;
onCancel?: Function;
}
export declare enum NODE_ENV {
/**
*开发环境
*/
development = "development",
/**
*生产环境
*/
production = "production",
}
export const _: any;
export const $: any;
declare global {
const $: any;
const _: any;
}
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
export interface VueGlobal {
$conf: BaseConf;
$utils: typeof Utils;
[key:string]:any;
}
+140
View File
@@ -0,0 +1,140 @@
import { BaseDict } from "./Basics";
export enum OperatorEnum {
add,
del,
edit,
sel,
}
/**
* 管理端访问级别(与后端 AccessLevelEnum / GetUserScope.level 对齐,后端单点判定)。
* 注意:School 由旧值 2 改为 3(原枚举值无消费方依赖具体数值,安全重排)。
*/
export enum UserRoleEnum {
SuperAdmin = 1,
Bureau,
School,
None,
}
//学段
export enum SchoolStage {
PRIMARY = 2,
MIDDLE,
HIGH,
}
export const SchoolStageDict: BaseDict[] = [
{
k: SchoolStage.PRIMARY,
v: "小学",
},
{
k: SchoolStage.MIDDLE,
v: "初中",
},
{
k: SchoolStage.HIGH,
v: "高中",
},
];
export enum RootNode {
/** 资源 */
ZIYUANKU = 1,
/** 辅导 */
TUTORING = 2,
/** 题库 */
QUESTION = 3,
/** 试卷 */
SHIJUAN = 4,
/** 专项 */
ZHUANXIANG = 5,
}
export const RootNodeDict: BaseDict[] = [
{
k: RootNode.ZIYUANKU,
v: "资源",
path: "ziyuan",
},
{
k: RootNode.TUTORING,
v: "辅导",
path: "fudao",
},
{
k: RootNode.QUESTION,
v: "题库",
path: "tiku",
},
{
k: RootNode.SHIJUAN,
v: "试卷",
path: "shijuan",
},
{
k: RootNode.ZHUANXIANG,
v: "专项",
path: "zhuanxiang",
},
];
export enum BooxType {
/** 教材 */
TextBook = 1,
/** 教辅 */
TeachingTutoring,
}
export const BooxTypeDict: BaseDict[] = [
{
k: BooxType.TextBook,
v: "教材",
},
{
k: BooxType.TeachingTutoring,
v: "教辅",
},
];
//学校学段学制
export interface SchLenLevel {
stage: SchoolStage;
name: string;
grades: LenGrade[];
}
export interface LenGrade {
gradeId: number;
gradeName: string;
stage: number;
}
export interface AcademicYear {
year: number;
semester: number | null;
name: string;
}
export interface Subject {
id: number;
tenantId: number;
baseId: null;
creatorId: number;
createDate: string;
name: string;
code: string;
orderNum: number;
alias: string;
stage: number;
enable: boolean;
disabled: boolean;
}
/**
* 1 积分详情
* 2 分数详情
*/
export type ScoreModalType = 1 | 2;
+13
View File
@@ -0,0 +1,13 @@
/**
* @description 扩展ruoter-meta的类型 此处必须要export {} 不然找不到类型
*/
declare module "vue-router" {
interface RouteMeta {
title?: string;
icon?: string;
modId?: number;
parentId?: number;
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
import Vue, { VNode } from "vue";
declare global {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode {}
// tslint:disable no-empty-interface
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+155
View File
@@ -0,0 +1,155 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import { rememberReturnUrl } from "@/utils/ssoReturn";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
injectHeaders?: (method: Method) => void;
/** 加载开始 */
onLoadingStart?: () => void;
/** 加载结束 */
onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/
onBizError?: (msg: string) => void;
/** http 错误提示(status !== 200)*/
onHttpError?: (msg: string) => void;
}
/** 平台统一响应信封(yh-api-std 的 ApiResponse 形态) */
export interface BschResponse<T = any> {
code: number;
msg?: string;
data: T;
urlToRedirectTo?: string;
}
/* ============= 默认空实现 ============= */
const defHooks: Required<RequestHooks> = {
injectHeaders: () => {},
onLoadingStart: () => {},
onLoadingEnd: () => {},
onBizError: () => {},
onHttpError: () => {},
};
/* ============= 错误处理(与 UserBase 同形,适配平台信封) ============= */
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
let msg = "网络错误";
switch (status) {
case 401:
case 403:
break;
case 404:
msg = "请求的资源不存在!";
break;
default:
msg = res?.msg || "内部服务错误!";
}
throw new Error(msg);
}
// 业务层错误
switch (res.code) {
case 0:
return res.data ?? (null as T);
case 302:
console.log(res);
// 后端 CAS 门已拼好登录地址(含 service=会话建立端点),整页跳过去即可。
// 跳转前记下当前地址:固定 service 的回跳只能落默认页,hash 页签靠前端记账换回
//(原因见 utils/ssoReturn.ts——不能照 Notice 把 hex 拼进固定 service)。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,全局 onBizError 与
// 各页面 catch 都不再触发——否则会弹一个"redirect"错误提示(Notice 同款处理)。
rememberReturnUrl();
window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
throw new Error(res.msg || "业务错误");
}
}
/* ============= 工厂函数 ============= */
export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, statesHook: StatesHook<any> = VueHook) {
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
...defHooks,
...hooks,
};
let reqCount = 0;
let timer: ReturnType<typeof setTimeout>;
/* ---- 请求拦截 ---- */
function beforeRequest(method: Method) {
reqCount++;
if (reqCount === 1) timer = setTimeout(() => onLoadingStart(), 800);
Promise.resolve().then(() => {
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
});
// 用户注入票据/角色等头
injectHeaders(method);
method.config.credentials = "include";
// 通用头。yschapirequest 是治理面 CAS 门识别 SPA 的标记头(与平台
// CasSsoApiRedirectFilter 同名同值):带它,未登录才返回 200+{code:302,urlToRedirectTo}
// 让本页去整页跳 SSO;不带,网关按裸调用方出 401 json。
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
});
}
/* ---- 响应拦截 ---- */
async function onSuccess(response: Response) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const res: BschResponse = await response.json();
try {
return errorHandle(res, response.status);
} catch (e: any) {
console.log(e);
onBizError(e.message);
return Promise.reject(e);
}
}
async function onError(err: any) {
reqCount = 0;
clearTimeout(timer);
onLoadingEnd();
const msg = err?.message || "网络请求出错";
onHttpError(msg);
return Promise.reject(err);
}
async function onComplete(method: Method) {
if (method.fromCache) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
}
}
return createAlova({
statesHook,
requestAdapter: fetchAdapter(),
baseURL,
cacheFor: null,
beforeRequest,
responded: { onSuccess, onError, onComplete },
});
}
+36
View File
@@ -0,0 +1,36 @@
// @/utils/alova/alova-iview.ts
import { createBschAlova } from "./alova-core";
import { Message } from "view-ui-plus";
import conf from "@/assets/config/host.config";
/* 全局 loading 遮罩实例 */
let spinInstance: any = null;
/* 主动关闭 loading */
function closeLoading() {
if (spinInstance) {
Message.destroy();
spinInstance = null;
}
}
const baseUrl = conf.serviceHost;
/* 创建带 View Design UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
/* 无登录态/角色头:网关管理面对外域已关闭,对内由 Caddy 白名单与后续网关侧鉴权负责 */
injectHeaders() {},
/* 2. 加载动画 – View Design Spin */
onLoadingStart() {
spinInstance = Message.loading("加载中...");
},
onLoadingEnd: closeLoading,
onBizError(msg: string) {
if (!!msg) Message.error(msg);
},
onHttpError(msg: string) {
Message.error({ title: "网络错误", content: msg });
},
});
+94
View File
@@ -0,0 +1,94 @@
/**
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
* > localStorage["yh-avatar"](用户自选,纯字符串)
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
* > "text"(系统默认)
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
*/
export type AvatarStyle = "text" | "silhouette";
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
removeItem(k: string): void;
}
export const AVATAR_STORAGE_KEY = "yh-avatar";
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
return raw === "text" || raw === "silhouette" ? raw : null;
}
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
function readAvatarOverride(): AvatarStyle | null {
try {
const search = (globalThis.location && globalThis.location.search) || "";
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
return null;
}
}
/**
* 生效样式合成(与 resolveEffectiveThemeState 同构):
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
* > DEFAULT_AVATAR_STYLE。
*/
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
let style: AvatarStyle | null = null;
if (storage) {
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
}
if (!style) style = DEFAULT_AVATAR_STYLE;
return readAvatarOverride() || style;
}
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(AVATAR_STORAGE_KEY, style);
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.removeItem(AVATAR_STORAGE_KEY);
} catch {
/* 隐私模式静默 */
}
}
/**
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
*/
export function pickAvatarText(name: string | null | undefined): string {
let s = String(name ?? "");
const at = s.indexOf("@");
if (at > -1) s = s.slice(0, at);
const ch = [...s.trim()][0];
if (ch === undefined) return "";
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
if (/^[0-9]$/.test(ch)) return "";
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
return ch;
}
+4
View File
@@ -0,0 +1,4 @@
import { ref } from "vue";
export function useCompRef<T extends abstract new (...args: any) => any>(_comp: T) {
return ref<InstanceType<T>>();
}
+7
View File
@@ -0,0 +1,7 @@
import { VueGlobal } from "@/types/Basics";
import { ComponentInternalInstance, getCurrentInstance } from "vue";
export default function useCurrentInstance(): VueGlobal {
const { appContext } = getCurrentInstance() as ComponentInternalInstance;
const proxy = appContext.config.globalProperties;
return proxy as unknown as VueGlobal;
}
+36
View File
@@ -0,0 +1,36 @@
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
/**
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
*
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
* 都不再需要同步改任何魔法数字。
*
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
*/
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
const height = ref(0);
let ro: ResizeObserver | null = null;
const measure = () => {
if (el.value) height.value = Math.floor(el.value.clientHeight);
};
const observe = () => {
ro?.disconnect();
if (!el.value || typeof ResizeObserver === "undefined") return;
measure();
ro = new ResizeObserver(measure);
ro.observe(el.value);
};
onMounted(observe);
// v-if / v-show 切换导致容器重建时,重新挂观察
watch(el, observe);
onBeforeUnmount(() => ro?.disconnect());
return height;
}
+5
View File
@@ -0,0 +1,5 @@
type PathType = "image" | "icons";
export const requireImg = (imgPath: string, basePath?: PathType) => {
basePath = basePath || "image";
return new URL(`../../assets/${basePath}/${imgPath}`, import.meta.url).href;
};
+37
View File
@@ -0,0 +1,37 @@
import { ref, watch, type Ref } from "vue";
/**
* 将任意响应式数据与 sessionStorage 双向绑定
* @param key sessionStorage 的 key
* @param def 默认值(当 sessionStorage 没有时)
* @param reviver 可选:解析时用 JSON.parse 的第二个参数
* @returns Ref<T>
*/
export function useSessionStorage<T>(
key: string,
def: T,
reviver?: (this: any, key: string, value: any) => any,
): Ref<T> {
/* ---------- 初始化 ---------- */
const raw = sessionStorage.getItem(key);
let init: T = def;
if (raw) {
try {
init = JSON.parse(raw, reviver);
} catch {
/* 解析失败回退默认值 */
}
}
const state = ref<T>(init) as Ref<T>;
/* ---------- 变化时落盘 ---------- */
watch(
state,
newVal => {
sessionStorage.setItem(key, JSON.stringify(newVal));
},
{ deep: true, flush: "post" },
);
return state;
}
+366
View File
@@ -0,0 +1,366 @@
import { IObj } from "@/types/Basics";
import { resolveComponent } from "vue";
export default class Utils {
/**
* 格式化日期
*
* @param {(Date | string)} 日期
* @param {string} fmt 格式化字符串
* @returns {string} 日期字符串
* @memberof Utils
*/
public static dateFormat(date: Date | string, fmt: string): string {
if (typeof date === "string") {
// 网关 Jackson 默认序列化为 ISO(2026-09-23T14:20:12.333):斜杠化后还须去 T,否则 new Date 解析成 Invalid Date
date = date.replace(/\-/g, "/").replace(/T/, " ").replace(/Z$/, "");
}
let dt = new Date(date);
let o: IObj = {
"M+": dt.getMonth() + 1, //月份
"d+": dt.getDate(), //日
"H+": dt.getHours(), //小时
"m+": dt.getMinutes(), //分
"s+": dt.getSeconds(), //秒
"q+": Math.floor((dt.getMonth() + 3) / 3), //季度
S: dt.getMilliseconds(), //毫秒
};
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (dt.getFullYear() + "").substr(4 - RegExp.$1.length));
for (let k in o)
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ("00" + o[k]).substr(("" + o[k]).length));
return fmt;
}
/**
* 获取地址栏指定参数
*/
public static getUrlParam(name: string): string | null {
let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
let r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
else return null;
}
/**
* 生成uuid
*/
public static uuid(): string {
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
let r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 返回文件大小
*/
public static getFileSize(size: number): string {
if (!size) return "";
if (size / 1024 < 1024) {
size = Math.ceil(size / 1024);
return size + "KB";
} else if (size / (1024 * 1024) < 1024) {
size = Math.ceil((size / (1024 * 1024)) * 100) / 100;
return size + "MB";
} else if (size / (1024 * 1024 * 1024) < 1024) {
size = Math.ceil((size / (1024 * 1024 * 1024)) * 100) / 100;
return size + "GB";
} else return "";
}
/**
* 在线预览大小上限(字节):日志大字段(请求体/响应)超过此值时隐藏"在线预览"、只保留"下载"。
* 浏览器 JSON 美化 + <pre> 渲染在数百 KB 起会卡顿,50KB 为体验无损的保守阈值。
*/
public static readonly PREVIEW_MAX_BYTES = 50 * 1024;
/**
* 是否允许在线预览:有内容(size > 0)且字节数不超过阈值。
* size 为 null/undefined/0(未记录请求体、saveResponse=false、响应为空)→ 不可预览,仅下载。
*/
public static canPreview(size?: number | null): boolean {
return size != null && size > 0 && size <= Utils.PREVIEW_MAX_BYTES;
}
/**
* 删除指定键
*/
public static delKey(obj: IObj, ...args: string[]) {
args.forEach(v => delete obj[v]);
return obj;
}
/**
* iview Tooltip封装
*/
public static operatingHandle(
h: any,
info: string,
iconClass: string,
callback: Function,
disabled: boolean = false,
) {
if (disabled) iconClass += " disabled";
return h(
resolveComponent("Tooltip"),
{
content: info,
placement: "bottom",
transfer: true,
},
{
default: () => [
h(
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
},
null,
),
],
},
);
}
public static operatingTextHandle(
h: any,
info: string,
className: string,
callback: Function,
disabled: boolean = false,
) {
if (disabled) className += " disabled";
return h(
"div",
{
class: `operating-link ${className}`,
onClick: disabled ? null : callback,
},
info,
);
}
/**
* 计算表格高度
*/
public static useTableHeight({
isSearch = false,
isPage = false,
isTabs = false,
isOperat = false,
otherH = 0,
} = {}) {
let bodyH = document.body.clientHeight,
headerH = 56,
searchH = 52,
operatH = 48,
tabsH = 56,
pageH = 72,
paddingH = 8;
let tableH = bodyH - headerH - paddingH;
isOperat && (tableH -= operatH);
isTabs && (tableH -= tabsH);
isSearch && (tableH -= searchH);
isPage && (tableH -= pageH);
otherH > 0 && (tableH -= otherH);
return tableH;
}
/**
* 数字转大写汉字
* @param num
* @returns
*/
public static convertToChinaNum(num: number) {
var arr1 = new Array("零", "一", "二", "三", "四", "五", "六", "七", "八", "九");
var arr2 = new Array(
"",
"十",
"百",
"千",
"万",
"十",
"百",
"千",
"亿",
"十",
"百",
"千",
"万",
"十",
"百",
"千",
"亿",
); //可继续追加更高位转换值
if (!num || isNaN(num)) {
return "零";
}
var english = num.toString().split("");
var result = "";
for (var i = 0; i < english.length; i++) {
var des_i = english.length - 1 - i; //倒序排列设值
result = arr2[i] + result;
var arr1_index: any = english[des_i];
result = arr1[arr1_index] + result;
}
//将【零千、零百】换成【零】 【十零】换成【十】
result = result.replace(/零(千|百|十)/g, "零").replace(/十零/g, "十");
//合并中间多个零为一个零
result = result.replace(/零+/g, "零");
//将【零亿】换成【亿】【零万】换成【万】
result = result.replace(/零亿/g, "亿").replace(/零万/g, "万");
//将【亿万】换成【亿】
result = result.replace(/亿万/g, "亿");
//移除末尾的零
result = result.replace(/零+$/, "");
//将【零一十】换成【零十】
//result = result.replace(/零一十/g, '零十');//貌似正规读法是零一十
//将【一十】换成【十】
result = result.replace(/^一十/g, "十");
return result;
}
/**
* 对象转url参数
* @param num
* @returns
*/
public static createUrl(url: string, query: any) {
var temp: any[] = [];
for (var k in query) {
if (query[k] != null) temp.push(k + "=" + query[k]);
}
return url + "?" + temp.join("&");
}
/**
* 获取当前角色信息
* @returns
*/
public static getCurrentRoleInfo<T>() {
let currentRole = localStorage.getItem("currentRole");
if (!currentRole) return null;
let { schId, roleId, childId, userId } = JSON.parse(decodeURI(currentRole ?? ""));
return { schId, roleId, childId, userId } as T;
}
/**
* tree对象转化为数组
* @param tree
* @returns
*/
public static treeToArray(tree: any[]): any[] {
return tree.reduce((res, item) => {
const { children, ...i } = item;
return res.concat(i, children && children.length ? this.treeToArray(children) : []);
}, []);
}
/**
* 数组转化为tree对象
* @param tree
* @returns
*/
public static arrayToTree(arr: any[], pid: number): any[] {
return arr
.filter(item => item.pid === pid)
.map(item => ({ ...item, children: this.arrayToTree(arr, item.id) }));
}
//字符串的16进制表达
public static strToHexCharCode(_str: string) {
if (_str === "") return "";
var hexCharCode: string[] = [];
for (var i = 0; i < _str.length; i++) {
var str = _str.charCodeAt(i).toString(16);
if (_str.length == 1) str += "0" + _str;
hexCharCode.push(str);
}
return hexCharCode.join("");
}
public static hexToString(hex: string) {
if (hex === "") return "";
// 处理奇数长度,补前导零
if (hex.length % 2 !== 0) {
hex = "0" + hex;
[[3]];
}
let str = "";
for (let i = 0; i < hex.length; i += 2) {
// 每两位解析为一个字符的Unicode值
const hexPair = hex.substr(i, 2);
const charCode = parseInt(hexPair, 16);
str += String.fromCharCode(charCode);
[[14, 16]];
}
return str;
}
/**
* 将颜色值HEX格式转换为rgb的格式
* @param {hex} hex 需要转换的rgb字符串
* @param {number} opacity 透明度
* @return {string} ;
*/
public static hexToRgb(hex: string, opacity: number = 1) {
let str = hex.replace("#", "");
if (str.length % 3) {
return "hex格式不正确!";
}
//获取截取的字符长度
let count = str.length / 3;
//根据字符串的长度判断是否需要 进行幂次方
let power = 6 / str.length;
let r = parseInt("0x" + str.substring(0 * count, 1 * count)) ** power;
let g = parseInt("0x" + str.substring(1 * count, 2 * count)) ** power;
let b = parseInt("0x" + str.substring(2 * count)) ** power;
return `rgb(${r}, ${g}, ${b},${opacity})`;
}
public static removeHtmlTag(str: string) {
str = str.replace(/<\/?[^>]*>/g, ""); //去除HTML tag
str = str.replace(/[ | ]*\n/g, "\n"); //去除行尾空白
str = str.replace(/\n[\s| | ]*\r/g, "\n"); //去除多余空行
str = str.replace(/ /gi, ""); //去掉
return str;
}
public static unpackPerms = (bits: number) => [
!!(bits & 1), // create
!!(bits & 2), // update
!!(bits & 4), // move
!!(bits & 8), // delete
];
public static copyToClipboard(text: string) {
const input = document.createElement("input");
input.setAttribute("value", text);
document.body.appendChild(input);
input.select();
try {
const successful = document.execCommand("copy");
if (successful) {
return Promise.resolve();
} else {
return Promise.reject();
}
} catch (err) {
return Promise.reject(err);
} finally {
document.body.removeChild(input);
}
}
public static sleep(ms: number, callback?: () => void): Promise<void> {
return new Promise<void>(resolve => {
setTimeout(() => {
callback?.();
resolve();
}, ms);
});
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* SSO 登录往返的「回到原页签」记账(纯前端)。
* <p>
* <b>为什么不是 Notice 的 {@code ;redirectToReferer=<hex>} 那套</b>:Notice 把 hex 拼进 service
* 参数,再靠校验过滤器从<b>实时请求 URI</b> 重算出同一个带 hex 的 service,两端逐字节一致才过得了
* CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出
* 带前缀的地址,只能改用<b>固定 service</b>(见后端 {@code SsoAddressing})——固定值里塞不进每次
* 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败
* → 又是一次 302,死循环。
* <p>
* 所以回跳目标不下服务端:整页跳去登录前把它记在 {@code sessionStorage}(同标签页、跨站点往返仍在),
* 回到控制台壳页时读出来换回原页签。副作用为零,且不引入任何服务端开放重定向面。
*/
const RETURN_KEY = "ysch-return-url";
/** 整页跳去 SSO 之前调用:记下当前控制台地址(含 hash 路由) */
export function rememberReturnUrl(href: string = window.location.href): void {
try {
sessionStorage.setItem(RETURN_KEY, href);
} catch (e: any) {
// 隐私模式/配额满:最坏只是回到默认页,不影响登录本身
}
}
/**
* 取出并清掉回跳目标,返回 vue-router 的 hash 路由段(如 {@code /ledger?pageIndex=2})。
* 只认「同 origin + 同一壳页」的地址,其余一律丢弃——防把用户导向站外或别的应用。
*/
export function consumeSsoReturnUrl(): string | null {
let raw: string | null = null;
try {
raw = sessionStorage.getItem(RETURN_KEY);
if (raw) sessionStorage.removeItem(RETURN_KEY);
} catch (e: any) {
return null;
}
if (!raw) return null;
try {
const u = new URL(raw);
if (u.origin !== window.location.origin) return null;
if (u.pathname !== window.location.pathname) return null;
const route = u.hash.replace(/^#\/?/, "/");
return route && route !== "/" ? route : null;
} catch (e: any) {
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 超管门槛(治理面前置守卫):GET 同源 /userbase/base/User/CurrentUser,
* 响应 {code:0, data:{isSuperAdmin, ...}}——非超级管理员整页显示「暂无权限」。
*
* 为什么用裸 fetch 不走 Apis/alova:
* - alova 实例 baseUrl 挂在网关 serviceHost(/open-gateway 前缀)上,而这条是平台
* userbase 的接口;且全局 loading/onBizError 会在门槛探测期弹遮罩与错误 toast,
* 门槛态由页面自己渲染,不需要全局报错。
* - 相对路径生产恒成立:控制台与 userbase 同挂 base 域,页面 /open-gateway/front/
* 发 /userbase/... 即同源带 Cookie;本地 dev 由 vite /userbase 代理兜到 base 域,
* dev:mock 由内置 mock 直接应答(YH_MOCK_SUPER=0 演示非超管态)。
*
* 判据宽容解析:isSuperAdmin 兼容 true/1/"1"/"true"(UserBase 后端 type=1 口径透传)。
* 只有「拿到响应且明确非超管」才拦截(denied);接口不可达/非 JSON/非 2xx 视为
* error——本地无平台 Cookie、userbase 故障等场景不因此锁死治理面(门槛是前端体验层,
* 真正的访问控制是后端 CAS 门)。本地开发可在 .env.dev 设 VITE_SUPER_GATE=off 旁路。
*/
export type SuperGate = "pending" | "allowed" | "denied" | "error";
export interface CurrentUserState {
gate: SuperGate;
/** 接口 data 里的展示名(可空):顶栏姓名/头像首字用 */
name: string;
}
function isTruthyFlag(v: any): boolean {
return v === true || v === 1 || v === "1" || v === "true";
}
export async function checkSuperAdmin(): Promise<CurrentUserState> {
if (import.meta.env.DEV && import.meta.env.VITE_SUPER_GATE === "off") {
return { gate: "allowed", name: "" };
}
try {
const res = await fetch("/userbase/base/User/CurrentUser", {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
if (!res.ok) return { gate: "error", name: "" };
const body: any = await res.json();
if (!body || body.code !== 0 || !body.data) return { gate: "error", name: "" };
const data = body.data;
const name = String(data.name ?? data.userName ?? data.nickname ?? "");
return { gate: isTruthyFlag(data.isSuperAdmin) ? "allowed" : "denied", name };
} catch {
return { gate: "error", name: "" };
}
}
+165
View File
@@ -0,0 +1,165 @@
// @ts-nocheck
/* 本端跑 strict vue-tsc(front/MOCK.md),通道/四端同构源落本端的既有约定照 theme.ts 补
@ts-nocheck:正文与 UserBase 同构源保持逐字节一致(MD5 973393684783c740399b8eb9e97369bc),
仅顶部此二行 + 下方注释为本端补丁;strict 下两处告警均无害——
L16 的 h 被各渲染器形参遮蔽(导入冗余)、actionMore 的 it.on() 形参在 normalize 已闭包绑定。 */
/**
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
*
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
*
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
*/
import { h, resolveComponent } from "vue";
export interface ActionItem {
/** 稳定标识:溢出分桶按 key 去重,必填 */
key: string;
label: string;
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
on: (row: any) => void;
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
icon?: string;
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
danger?: boolean;
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
when?: (row: any) => boolean;
/** 直接置灰 */
disabled?: boolean;
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
disabledWhen?: (row: any) => boolean;
}
export interface ActionCellOpts {
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
mode?: "icon" | "text";
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
collapsible?: boolean;
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
maxInline?: number;
/** 列宽;缺省 icon = n*40+40 / text = 180 */
width?: number;
align?: "center" | "left" | "right";
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
fixed?: "right" | "left" | false;
}
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
export function actionIcon(h: any, item: ActionItem) {
return h(
resolveComponent("Tooltip"),
{ content: item.label, placement: "bottom", transfer: true },
{
default: () => [
h("div", {
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
}),
],
},
);
}
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
export function actionText(h: any, item: ActionItem) {
return h(
"div",
{
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
},
item.label,
);
}
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
export function actionMore(h: any, items: ActionItem[]) {
return h(
resolveComponent("Dropdown"),
{ class: "op-more", placement: "bottom-end", transfer: true },
{
default: () => [
h("div", { class: "operating-link op-more-label" }, [
"更多",
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
]),
],
list: () => [
h(resolveComponent("DropdownMenu"), {}, {
default: () => items.map((it) =>
h(
resolveComponent("DropdownItem"),
{
key: it.key,
divided: !!(it.danger && items.length > 1),
disabled: !!it.disabled,
class: it.danger ? "op-item-danger" : "",
onClick: () => it.on(),
},
{ default: () => it.label },
),
),
}),
],
},
);
}
/* ============ 轴 2:归一化(纯数据) ============ */
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
return items
.filter((it) => (it.when ? it.when(row) : true))
.map((it) => ({
...it,
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
on: () => it.on(row),
}))
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
}
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
const inlineKeys = new Set(inline.map((it) => it.key));
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
}
/* ============ 组装层 ============ */
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
const { inline, overflow } = (opts.collapsible ?? true)
? splitActions(items, opts.maxInline ?? 2)
: { inline: items, overflow: [] };
const render = RENDERERS[mode] || actionText;
const children = inline.map((it) => render(h, it));
if (overflow.length) children.push(actionMore(h, overflow));
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
}
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
return {
title: "操作",
key: "action",
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
align: opts.align ?? "center",
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
};
}
+279
View File
@@ -0,0 +1,279 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">
应用管理(open_api_app):ANON=仅内部入口免签,仍须带 X-App-Key;appKey 填 <b>*</b> =
匿名窗口(无票据放行,≤31 天强制到期);改回 SIGNED 前先确认密钥链有该键。
</span>
<div class="fr">
<Button type="primary" @click="openCreate">新增应用</Button>
<Button class="ml8" @click="refresh">刷新</Button>
</div>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows">
<template #appKey="{ row }">
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
<span v-else class="mono">{{ row.appKey }}</span>
</template>
<template #appName="{ row }">
<Input v-model="row.appName" size="small" placeholder="-" />
</template>
<template #authMode="{ row }">
<Select v-model="row.authMode" size="small" style="width: 110px"
@on-change="onModeChange(row)">
<Option value="SIGNED">SIGNED</Option>
<Option value="ANON">ANON</Option>
</Select>
</template>
<template #expireAt="{ row }">
<template v-if="row.authMode === 'ANON'">
<Input v-model="row.expireAtText" size="small" placeholder="yyyy-MM-dd HH:mm:ss"
style="width: 170px" :class="expireClass(row)" />
<div :class="expireClass(row)" class="expire-hint">{{ expireHint(row) }}</div>
</template>
<span v-else class="expire-none">-</span>
</template>
<template #enabled="{ row }">
<i-switch v-model="row.enabled" size="small" />
</template>
<template #remark="{ row }">
<Input v-model="row.remark" size="small" placeholder="-" />
</template>
<template #gmtModified="{ row }">
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
</template>
</TablePageAlovaNew>
<Modal v-model="createShow" :mask-closable="false" width="560" title="新增应用">
<Form :label-width="110" class="app-create-form">
<FormItem label="appKey:" required>
<Input v-model="createForm.appKey" placeholder="调用方 appKey(唯一;填 * = 匿名窗口行)" />
</FormItem>
<FormItem label="展示名:">
<Input v-model="createForm.appName" placeholder="可选" />
</FormItem>
<FormItem label="authMode:" required>
<Select v-model="createForm.authMode" style="width: 180px">
<Option value="SIGNED">SIGNED(验签,缺省)</Option>
<Option value="ANON">ANON(内部入口免签)</Option>
</Select>
</FormItem>
<FormItem v-if="createForm.authMode === 'ANON'" label="免签到期:" required>
<Input v-model="createForm.expireAt" placeholder="yyyy-MM-dd HH:mm:ss(必填,≤31 天)" />
</FormItem>
<FormItem label="备注:">
<Input v-model="createForm.remark" placeholder="可选" />
</FormItem>
<div class="form-tip" v-if="createForm.authMode === 'ANON'">
免签最长 31 天(每次保存重新起算),到期惰性恢复验签;ANON 应用不要求密钥链有键。
</div>
</Form>
<template #footer>
<Button @click="createShow = false">取消</Button>
<Button type="primary" :loading="creating" @click="submitCreate">确定</Button>
</template>
</Modal>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'AppList' });
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const savingId = ref<number | null>(null);
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.apps({ params, cacheFor: null });
const columns = [
{ title: 'id', key: 'id', width: 70, align: 'center' },
{ title: 'appKey', slot: 'appKey', width: 160 },
{ title: '展示名', slot: 'appName', width: 150 },
{ title: 'authMode', slot: 'authMode', width: 130 },
{ title: '免签到期', slot: 'expireAt', minWidth: 210 },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ title: '备注', slot: 'remark', minWidth: 140 },
{ title: '更新时间', slot: 'gmtModified', width: 170 },
actionColumn(
[
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
],
{ mode: 'text', width: 90, fixed: 'right' },
),
];
/** 服务端 expireAt 为 ISO 串:补一行内编辑用的 "yyyy-MM-dd HH:mm:ss" 文本 */
function normalizeRows(rows: any[]) {
return (rows || []).map((r: any) => ({
...r,
expireAtText: r.expireAt ? Utils.dateFormat(r.expireAt, 'yyyy-MM-dd HH:mm:ss') : '',
}));
}
const DAY_MS = 24 * 3600 * 1000;
const MAX_TTL_DAYS = 31;
/** 到期可视化:已过期标红、临期(≤3 天)标橙 */
function expireState(row: any): 'expired' | 'soon' | 'ok' {
if (!row.expireAt) return 'expired';
const remain = new Date(String(row.expireAt).replace(/-/g, '/').replace(/T/, ' ')).getTime() - Date.now();
if (remain <= 0) return 'expired';
if (remain <= 3 * DAY_MS) return 'soon';
return 'ok';
}
function expireClass(row: any) {
const s = expireState(row);
return { 'expire-danger': s === 'expired', 'expire-warning': s === 'soon' };
}
function expireHint(row: any) {
const s = expireState(row);
if (s === 'expired') return '已过期(免签失效,等同无行)';
if (s === 'soon') return '临期,续期请重新保存';
return `有效期 ≤${MAX_TTL_DAYS} 天,续期重新起算`;
}
/** 切到 SIGNED 即视为放弃免签:清空行内到期文本(后端落库置 NULL) */
function onModeChange(row: any) {
if (row.authMode !== 'ANON') {
row.expireAtText = '';
}
}
function refresh() {
tableRef.value?.refreshData();
}
/** 行内编辑:只提交治理字段(appKey 是行身份,后端不接受改动);ANON 行连同到期一起提交 */
async function save(row: any) {
savingId.value = row.id;
try {
const data: Record<string, any> = {
appName: row.appName,
authMode: row.authMode,
enabled: row.enabled,
remark: row.remark,
};
if (row.authMode === 'ANON') {
data.expireAt = row.expireAtText;
}
const d: any = await Apis.GatewayAdmin.updateApp({
pathParams: { id: row.id },
data,
});
Message.success(`已保存,应用表加载 ${d?.apps ?? '-'} 条`);
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里只做回滚,避免双 toast
tableRef.value?.refreshData();
} finally {
savingId.value = null;
}
}
/* 新增应用弹窗(O3:治理页闭环,不留"只有 UI 没有后端"断层) */
const createShow = ref(false);
const creating = ref(false);
const createForm = reactive({
appKey: '',
appName: '',
authMode: 'ANON',
expireAt: '',
remark: '',
});
function openCreate() {
createForm.appKey = '';
createForm.appName = '';
createForm.authMode = 'ANON';
createForm.expireAt = '';
createForm.remark = '';
createShow.value = true;
}
async function submitCreate() {
if (!createForm.appKey || !createForm.appKey.trim()) {
Message.warning('appKey 必填');
return;
}
if (createForm.authMode === 'ANON' && !createForm.expireAt.trim()) {
Message.warning('authMode=ANON 时免签到期必填(≤31 天)');
return;
}
creating.value = true;
try {
const d: any = await Apis.GatewayAdmin.createApp({ data: { ...createForm } });
Message.success(`已创建,应用表加载 ${d?.apps ?? '-'} 条`);
createShow.value = false;
tableRef.value?.reloadData('add' as any);
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出
} finally {
creating.value = false;
}
}
</script>
<style lang="scss" scoped>
.search-wrap {
line-height: 32px;
.tip {
color: #848c98;
font-size: 13px;
b {
color: #ed4014;
}
}
.ml8 {
margin-left: 8px;
}
}
.mono {
font-family: Menlo, Consolas, monospace;
}
.window-tag {
color: #ff9900;
font-family: Menlo, Consolas, monospace;
font-weight: 600;
}
.expire-hint {
font-size: 12px;
line-height: 16px;
}
.expire-none {
color: #c5c8ce;
}
.expire-warning,
.expire-warning.expire-hint {
color: #ff9900;
}
.expire-danger,
.expire-danger.expire-hint {
color: #ed4014;
}
.form-tip {
margin: -6px 0 0 110px;
font-size: 12px;
color: #848c98;
line-height: 18px;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<Modal v-model="show" :mask-closable="false" width="640" title="台账详情" @on-visible-change="onVisibleChange">
<Form :label-width="120" class="ledger-detail-form">
<FormItem label="id:">
<span>{{ row?.id ?? '-' }}</span>
</FormItem>
<FormItem label="调用时间:">
<span>{{ formatTime(row?.ts) }}</span>
</FormItem>
<FormItem label="调用方 appKey:">
<span>{{ row?.callerAppKey ?? '-' }}</span>
</FormItem>
<FormItem label="target:">
<span>{{ row?.targetCode ?? '-' }}</span>
</FormItem>
<FormItem label="请求方法:">
<span>{{ row?.method ?? '-' }}</span>
</FormItem>
<FormItem label="请求路径:">
<span class="mono">{{ row?.path ?? '-' }}</span>
</FormItem>
<FormItem label="网关状态码:">
<span :class="statusClass">{{ row?.status ?? '-' }}</span>
</FormItem>
<FormItem label="后端状态码:">
<span>{{ row?.backendCode ?? '-' }}</span>
<div v-if="row?.backendCode == null" class="backend-tip">流式转发不解析业务 code,本期恒为空</div>
</FormItem>
<FormItem label="耗时:">
<span>{{ row?.costMs != null ? row.costMs + ' ms' : '-' }}</span>
</FormItem>
</Form>
<template #footer>
<Button type="primary" @click="show = false">关闭</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Utils from '@/utils';
interface Props {
modelValue: boolean;
row?: Record<string, any> | null;
}
const props = withDefaults(defineProps<Props>(), {
row: null,
});
const emit = defineEmits(['update:modelValue']);
const show = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
});
const statusClass = computed(() => {
const s = props.row?.status;
if (s >= 500) return 'text-error';
if (s >= 400) return 'text-warning';
return 'text-success';
});
function onVisibleChange(val: boolean) {
if (!val) emit('update:modelValue', false);
}
function formatTime(val: any): string {
if (!val) return '-';
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
}
</script>
<style lang="scss" scoped>
.ledger-detail-form {
:deep(.ivu-form-item) {
margin-bottom: 14px;
}
:deep(.ivu-form-item-label) {
color: #515a6e;
font-weight: 500;
}
.mono {
font-family: Menlo, Consolas, monospace;
word-break: break-all;
}
.backend-tip {
font-size: 12px;
color: #848c98;
line-height: 18px;
}
.text-success {
color: #19be6b;
}
.text-warning {
color: #ff9900;
}
.text-error {
color: #ed4014;
}
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
style="width: 180px" @on-search="handleSearch" @on-clear="handleSearch" />
<Input v-model="dataParams.callerAppKey" class="ml-12" placeholder="调用方 appKey" clearable
style="width: 180px" @on-search="handleSearch" @on-clear="handleSearch" />
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
<Button class="ml-12" @click="handleReset">重置</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import LedgerDetailModal from './LedgerDetailModal.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'LedgerList' });
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
interface DataParams {
targetCode?: string;
callerAppKey?: string;
}
const dataParams = ref<DataParams>({});
function handleSearch() {
tableRef.value?.refreshData();
}
function handleReset() {
dataParams.value = {};
handleSearch();
}
function clean(obj: Record<string, any>) {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
}
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.logs({
params: clean({
targetCode: dataParams.value.targetCode?.trim() || undefined,
callerAppKey: dataParams.value.callerAppKey?.trim() || undefined,
pageIndex: params.pageIndex,
pageSize: params.pageSize,
}),
cacheFor: null,
});
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
function statusClass(status: number) {
if (status >= 500) return 'text-error';
if (status >= 400) return 'text-warning';
return 'text-success';
}
const columns = [
{ type: 'index', title: '序号', width: 70, align: 'center' },
{
title: '时间',
key: 'ts',
width: 180,
render: (h: any, { row }: any) => h('span', row.ts ? Utils.dateFormat(row.ts, 'yyyy-MM-dd HH:mm:ss') : '-'),
},
{
title: '调用方 appKey',
key: 'callerAppKey',
width: 140,
ellipsis: true,
tooltip: true,
render: (h: any, { row }: any) => h('span', row.callerAppKey == null ? '-' : String(row.callerAppKey)),
},
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
{ title: '方法', key: 'method', width: 90, align: 'center' },
{ title: '请求路径', key: 'path', minWidth: 240, ellipsis: true, tooltip: true },
{
title: '状态码',
key: 'status',
width: 95,
align: 'center',
render: (h: any, { row }: any) => h('span', { class: statusClass(row.status) }, row.status ?? '-'),
},
{
title: '后端码',
key: 'backendCode',
width: 100,
align: 'center',
render: (h: any, { row }: any) => h('span', row.backendCode == null ? '-' : String(row.backendCode)),
},
{
title: '耗时',
key: 'costMs',
width: 110,
align: 'right',
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
},
actionColumn(
[
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
],
{ mode: 'text', width: 100, fixed: 'right' },
),
];
const detailVisible = ref(false);
const currentRow = ref<Record<string, any> | null>(null);
function openDetail(row: any) {
currentRow.value = row;
detailVisible.value = true;
}
</script>
<style lang="scss" scoped>
.search-wrap {
.ivu-select,
.ivu-input-wrapper,
.ivu-date-picker {
vertical-align: middle;
}
}
.text-success {
color: #19be6b;
}
.text-warning {
color: #ff9900;
}
.text-error {
color: #ed4014;
}
</style>
+256
View File
@@ -0,0 +1,256 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">
代理测试:输入票据与 oapi 地址,网关服务端签名后回环打真实链路(内部入口口径,一律验签)。
observe=true 时 200 不代表验签通过,最终判定看「访问台账」。
</span>
</div>
<div class="proxy-form">
<div class="form-row">
<Select v-model="form.method" style="width: 110px">
<Option v-for="m in METHODS" :key="m" :value="m">{{ m }}</Option>
</Select>
<Input v-model="form.url" class="grow" placeholder="http://网关地址/{target}/oapi/**,如 /userbase/oapi/user/byId?id=1" />
</div>
<div class="form-row">
<Input v-model="form.appKey" placeholder="appKey" style="width: 200px" />
<Input v-model="form.appSecret" type="password" placeholder="appSecret(留空 = 由网关密钥链解析)" style="width: 320px" />
<Input v-model="form.contentType" class="grow" placeholder="Content-Type" />
</div>
<div class="form-row" v-if="hasBody">
<Input v-model="form.body" type="textarea" :rows="6" placeholder="JSON 请求体(可选)" />
</div>
<div class="form-row">
<Button type="primary" :loading="sending" @click="send">发送</Button>
</div>
</div>
<div class="result" v-if="result">
<div class="status-line">
<Tag :color="statusColor(result.status)">{{ result.status }}</Tag>
<span class="dot">·</span>
<span>{{ result.latencyMs }}ms</span>
<span class="dot">·</span>
<span>target={{ result.target }}</span>
<span class="dot">·</span>
<span>{{ result.contentType || '-' }}</span>
<Tag v-if="result.bodyTruncated" color="warning">响应体超 256KB 已截断</Tag>
</div>
<code class="code-line request-url">{{ result.requestUrl }}</code>
<div v-if="result.error" class="error-line">{{ result.error }}</div>
<div class="sign-block">
<div class="block-title">签名调试</div>
<div class="kv-line">
<span class="k">appKey</span>
<span>{{ result.signature.appKey }}</span>
</div>
<div class="kv-line">
<span class="k">timestamp</span>
<span>{{ result.signature.timestamp }}</span>
</div>
<div class="kv-line">
<span class="k">secretSource</span>
<span>{{ result.signature.secretSource }}</span>
</div>
<div class="kv-line">
<span class="k">canonical</span>
<code class="code-line grow">{{ result.signature.canonical }}</code>
<Button size="small" @click="copyText(result.signature.canonical)">复制</Button>
</div>
<div class="kv-line">
<span class="k">signature</span>
<code class="code-line grow">{{ result.signature.signature }}</code>
<Button size="small" @click="copyText(result.signature.signature)">复制</Button>
</div>
</div>
<div class="block-title">响应体</div>
<pre class="res-body">{{ prettyBody }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
defineOptions({ name: 'ProxyTest' });
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
const form = reactive({
method: 'GET',
url: '',
appKey: '',
appSecret: '',
contentType: 'application/json',
body: '',
});
const hasBody = computed(() => ['POST', 'PUT', 'DELETE', 'PATCH'].includes(form.method));
const sending = ref(false);
const result = ref<any>(null);
async function send() {
if (!form.url.trim() || !form.appKey.trim()) {
Message.warning('url 与 appKey 必填');
return;
}
sending.value = true;
result.value = null;
try {
const d: any = await Apis.GatewayAdmin.proxyTest({
data: {
url: form.url,
method: form.method,
appKey: form.appKey,
appSecret: form.appSecret || undefined, // 空串不传 → 后端走 REDIS 解析
contentType: hasBody.value ? form.contentType : undefined,
body: hasBody.value && form.body.trim() ? form.body : undefined,
},
});
result.value = d;
} catch (e) {
// code=2 业务错由 alova 全局 onBizError 统一弹 toast
} finally {
sending.value = false;
}
}
function statusColor(s: number): string {
if (s >= 200 && s < 300) return 'green';
if (s >= 400 && s < 500) return 'warning';
if (s >= 500) return 'error';
return 'default';
}
const prettyBody = computed(() => {
const b = result.value?.body ?? '';
try {
return JSON.stringify(JSON.parse(b), null, 2);
} catch {
return b;
}
});
async function copyText(t: string) {
try {
await navigator.clipboard.writeText(t);
Message.success('已复制');
} catch {
Message.error('复制失败,请手动选中复制');
}
}
</script>
<style lang="scss" scoped>
.search-wrap {
line-height: 32px;
.tip {
color: #848c98;
font-size: 13px;
}
}
.proxy-form {
margin: 14px 0 18px;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.grow {
flex: 1;
}
.result {
.status-line {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 13px;
.dot {
color: #c5c8ce;
}
}
.code-line {
display: block;
background: #f5f7f9;
border: 1px solid #e3e8ee;
border-radius: 4px;
padding: 6px 10px;
font-family: Consolas, Menlo, monospace;
font-size: 12px;
color: #2d8cf0;
word-break: break-all;
}
.request-url {
white-space: nowrap;
overflow-x: auto;
margin-bottom: 12px;
}
.error-line {
color: #ed4014;
font-size: 13px;
margin-bottom: 12px;
}
.sign-block {
margin-bottom: 14px;
}
.block-title {
font-weight: bold;
font-size: 13px;
margin: 12px 0 8px;
}
.kv-line {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
.k {
flex: none;
width: 90px;
color: #848c98;
font-size: 13px;
text-align: right;
}
.code-line {
flex: 1;
margin-bottom: 0;
}
}
.res-body {
background: #f5f7f9;
border: 1px solid #e3e8ee;
border-radius: 4px;
padding: 10px 12px;
max-height: 400px;
overflow: auto;
font-family: Consolas, Menlo, monospace;
font-size: 12px;
margin: 0;
}
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps&gt;0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #visibility="{ row }">
<Select v-model="row.visibility" size="small" style="width: 110px">
<Option value="INTERNAL">INTERNAL</Option>
<Option value="EXTERNAL">EXTERNAL</Option>
</Select>
</template>
<template #authMode="{ row }">
<Select v-model="row.authMode" size="small" style="width: 120px">
<Option value="SIGNED">SIGNED</Option>
<Option value="ANON">ANON</Option>
</Select>
</template>
<template #cacheTtlSeconds="{ row }">
<Input v-model="row.cacheTtlSeconds" type="number" size="small" style="width: 80px" />
</template>
<template #cacheScope="{ row }">
<Select v-model="row.cacheScope" size="small" style="width: 100px">
<Option value="CALLER">CALLER</Option>
<Option value="GLOBAL">GLOBAL</Option>
</Select>
</template>
<template #rateLimitQps="{ row }">
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
</template>
<template #timeoutMs="{ row }">
<Input v-model="row.timeoutMs" type="number" size="small" style="width: 90px" placeholder="1000-120000" />
</template>
<template #requestBodyLimitMb="{ row }">
<Input v-model="row.requestBodyLimitMb" type="number" size="small" style="width: 90px" placeholder="1-100" />
</template>
<template #enabled="{ row }">
<i-switch v-model="row.enabled" size="small" />
</template>
<template #remark="{ row }">
<Input v-model="row.remark" size="small" placeholder="-" />
</template>
</TablePageAlovaNew>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'RouteList' });
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const savingId = ref<number | null>(null);
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.routes({ params, cacheFor: null });
const columns = [
{ title: 'id', key: 'id', width: 70, align: 'center' },
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
{ title: 'path', key: 'pathPattern', minWidth: 200, ellipsis: true, tooltip: true },
{ title: 'method', key: 'httpMethod', width: 90, align: 'center' },
{ title: 'visibility', slot: 'visibility', width: 130 },
{ title: 'authMode', slot: 'authMode', width: 140 },
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
{ title: 'timeout(ms)', slot: 'timeoutMs', width: 115, align: 'right' },
{ title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, align: 'right' },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ title: '备注', slot: 'remark', minWidth: 160 },
actionColumn(
[
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
],
{ mode: 'text', width: 90, fixed: 'right' },
),
];
function refresh() {
tableRef.value?.refreshData();
}
/** 只提交治理字段(target/pattern/method 是路由身份,后端不接受改动) */
async function save(row: any) {
savingId.value = row.id;
try {
const d: any = await Apis.GatewayAdmin.updateRoute({
pathParams: { id: row.id },
data: {
visibility: row.visibility,
authMode: row.authMode,
cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0,
cacheScope: row.cacheScope,
rateLimitQps: Number(row.rateLimitQps) || 0,
timeoutMs: Number(row.timeoutMs) || 30000,
requestBodyLimitMb: Number(row.requestBodyLimitMb) || 10,
enabled: row.enabled,
remark: row.remark,
},
});
Message.success(`已保存,注册表加载 ${d?.loaded ?? '-'} 条路由`);
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里只做回滚,避免双 toast
tableRef.value?.refreshData();
} finally {
savingId.value = null;
}
}
</script>
<style lang="scss" scoped>
.search-wrap {
line-height: 32px;
.tip {
color: #848c98;
font-size: 13px;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #baseUrl="{ row }">
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
</template>
<template #enabled="{ row }">
<i-switch v-model="row.enabled" size="small" />
</template>
<template #gmtModified="{ row }">
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
</template>
</TablePageAlovaNew>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'TargetList' });
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const savingId = ref<number | null>(null);
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.targets({ params, cacheFor: null });
const columns = [
{ title: 'id', key: 'id', width: 70, align: 'center' },
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ title: '更新时间', slot: 'gmtModified', width: 180 },
actionColumn(
[
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
],
{ mode: 'text', width: 90, fixed: 'right' },
),
];
function refresh() {
tableRef.value?.refreshData();
}
async function save(row: any) {
savingId.value = row.id;
try {
const d: any = await Apis.GatewayAdmin.updateTarget({
pathParams: { id: row.id },
data: {
baseUrl: row.baseUrl,
enabled: row.enabled,
},
});
Message.success(`已保存,目标表加载 ${d?.targets ?? '-'} 条`);
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里只做回滚,避免双 toast
tableRef.value?.refreshData();
} finally {
savingId.value = null;
}
}
</script>
<style lang="scss" scoped>
.search-wrap {
line-height: 32px;
.tip {
color: #848c98;
font-size: 13px;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
// import.meta.env 类型声明:VITE_* 仅供本地开发(.env.dev,vite dev --mode dev)读取;
// build(production mode)无 .env/.env.production,VITE_* 恒为 undefined,不会进构建产物。
// 线上环境差异由 env-config 运行时配置承担(见 host.config.ts)。
interface ImportMetaEnv {
readonly VITE_BASE_HOST: string;
readonly VITE_SSO_HOST: string;
readonly VITE_SERVICE_HOST: string;
readonly VITE_ZIYUANKU_HOST: string;
readonly VITE_ZHUANXIANG_HOST: string;
readonly VITE_IS_MOCK: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","types"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

Some files were not shown because too many files have changed in this diff Show More