feat: 办事大厅用户面前端(hall)首版入库——由 app/public 改名去 app 层;build.yml 修并发撞名容exit137
build / build (push) Successful in 3m6s

This commit is contained in:
chenxing
2026-09-17 09:12:03 +08:00
parent 6511923327
commit f87523c524
89 changed files with 16000 additions and 14 deletions
+15
View File
@@ -0,0 +1,15 @@
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
# ⚠️ VITE_* 仅本地开发生效。
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8086),
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
# 本地开发:站点标识与皮肤包(线上由 env-config 下发,build 产物不读 .env)
VITE_SITE_CODE=yhms
VITE_THEME=default
+9
View File
@@ -0,0 +1,9 @@
# 本地纯前端调试模式:仅 vite dev --mode mock 加载(build 产物不读任何 .env.*,
# 线上由 /api/env-config?target=front 运行时下发)。后端 SSO/base 不可达时启用内置 mock
# 启动:npm run dev:mock
VITE_BASE_HOST=
VITE_SSO_HOST=
VITE_SERVICE_HOST=http://localhost:8101
VITE_ZIYUANKU_HOST=
VITE_ZHUANXIANG_HOST=
VITE_IS_MOCK=true
+18 -12
View File
@@ -1,6 +1,7 @@
name: build
# 前端应用 CI:构建 → 打包 zip → 推 Nexus raw → 通知 projman 建档
# 公共办事大厅(用户面)CI:构建 → 打包 zip → 推 Nexus raw → 通知 projman 建档
# 本仓 = projman 应用 svc-hall 的 hall 前端目标(学生/教师/访客办理入口,URL /svc-hall/hall/)
# 触发:push 到 main / 手动 Run workflow
# 链路:clone 源码 -> node 容器 npm ci + npm run build -> 打包 dist 为 zip -> 推 Nexus raw -> 通知 projman 建档
# 依赖仓库 Secrets:DEPLOY_TOKEN(webhook 鉴权)、NEXUS_USER / NEXUS_PASSWORD(上传 zip 到 Nexus raw)
@@ -38,10 +39,16 @@ jobs:
- name: 构建前端(node 容器)
run: |
set -e
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild -w /build node:20 sleep 3600
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享
# 命名空间。旧写法固定名 frontbuild 会在多仓/多条 workflow 并发时互相 docker rm -f 掉
# 对方正在构建的容器,表现为无编译报错的 exit 137(SIGKILL,2026-09-10 三仓同推即撞)。
# 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
cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
@@ -49,13 +56,15 @@ jobs:
npm run build
'
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 到宿主工作目录
docker cp frontbuild:/build/dist ./dist
docker rm -f frontbuild
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
# 打包 dist → zip(vite outDir 已简化为直出 dist/,无中间层;进入 dist 打包,
# zip 根目录直接是 index.html/css/js。此前 outDir=dist/base 时曾误打 dist/ 整层,
# zip 根只有 base/ 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」)
- name: 打包产物
run: |
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -71,9 +80,7 @@ jobs:
NEXUS_USER: ${{ secrets.NEXUS_USER }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
run: |
set -e
NEXUS_URL="http://nexus.yunhaixingtong.com:8081/repository/front-hosted/${ZIP_FILE}"
# -f:HTTP >=400 时 curl 以非零退出,仓库不存在/认证失败必须让 job 变红,否则建档的是不存在的 URL
curl -sSf -X PUT \
-u "${NEXUS_USER}:${NEXUS_PASSWORD}" \
--upload-file "${ZIP_FILE}" \
@@ -84,7 +91,6 @@ jobs:
# 通知 projman:webhook 收到后建档(type=front, status=pending),部署由用户在「前端发布」Tab 手动触发
- name: 通知 projman 建档
run: |
set -e
curl -sSf -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
+29
View File
@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
yarn.lock
.npmrc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# scratch
.temp
+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
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}
+99 -2
View File
@@ -1,3 +1,100 @@
# svc-hall-hall
# 公共办事大厅前端(svc-hall-hall)
managed by projman
云海平台「办事大厅」的**用户面**前端:给学生 / 教师 / 访客提供**在线办理**入口——
事项大厅 → 在线办理(填表提交)→ 我的办件 → 办件详情(审批进度、流程图、撤回/重提)。
它是**独立前端项目**,与 `svc-hall/front`(后台管理端:事项库/字典/表单/办件记录)互不引用:
| | 本项目 `hall` | 兄弟项目 `front` |
| --- | --- | --- |
| 面向 | 学生 / 教师 / 访客 | 管理员(站点管理面) |
| 入口 | 门户指南页「我要办理」按钮 / 直接访问 | 平台工作台菜单(app_module) |
| 页面 | 事项大厅、在线办理、我的办件、办件详情 | 办事事项、服务字典、表单管理(设计器)、办件记录 |
| Projman | 应用 `svc-hall` 的前端目标 **`hall`** → `/svc-hall/hall/` | 前端目标 `front` → `/svc-hall/front/` |
| 开发端口 | **8089** | 8087 |
> 用户面为什么单独一个前端:管理端走平台后台布局(模块菜单 + 表格 CRUD),用户面要的是
> "大厅 + 表单 + 进度"的对外观感与更低的使用门槛;混在一个 SPA 里会让门户访客落进后台壳子
> (菜单/权限模型也完全不同)。参见《办事大厅独立应用与对接方案V2.0.md》。
## 本地启动
```bash
npm install
npm run dev # http://localhost:8089
```
`dev` 模式 `serviceHost` 留空,接口走同源相对路径 `/api/...`,由 vite proxy 转发到本地后端
`http://localhost:8086`(后端 CAS 过滤器对 `/api/*` 不回 CORS 头,浏览器直连会被拦,必须同源代理)。
本项目**不直连流程中心**:撤回 / 审批进度 / 流程图都由办事大厅后端中转(后端用平台 SDK
`YschFlowOpenApi` 出站),所以本地只需起 svc-hall 后端 + 流程中心后端(8085),
不需要为 flow-center 前端配 CORS。
## 脚本
| 命令 | 说明 |
| --- | --- |
| `npm run dev` | 本地联调(读 `.env.dev`:`VITE_SITE_CODE` 指定站点、`VITE_THEME` 指定皮肤) |
| `npm run dev:mock` | 纯前端模式(`.env.mock`) |
| `npm run build` | 构建产物直出 `dist/`(CI 打 zip 要求 zip 根直接有 `index.html`) |
## 运行期配置(`window.__APP_CONFIG__`)
优先级见 `src/assets/config/host.config.ts`:线上由 `index.html` 引导脚本同步拉
`/{contextPath}/api/env-config?target=hall`(projman 配置管理维护 `env-config-hall.json`);
本地走 `.env.dev` 的 `VITE_*`。
用户面特有的两个键:
| 键 | 作用 | 缺省行为 |
| --- | --- | --- |
| `siteCode` | 本部署对应的 CMS 站点标识(大厅/发起页定位用) | 退回 URL query 与 localStorage,再没有则页面提示"未配置站点" |
| `theme` / `themeParams` | 皮肤包名与颜色覆盖 | `default`;未注册的皮肤名**整页回退** default |
## 页面与路由(`src/router/routes.ts`,hash 路由)
| 路径 | 页面 | 说明 |
| --- | --- | --- |
| `/hall` | 事项大厅 | 已发布事项卡片列表 + 关键词/服务对象/分类/部门筛选(`/api/hall/*`) |
| `/apply` | 在线办理 | `?siteCode=&itemCode=`(与 CMS 指南页按钮同一深链形态) |
| `/my` | 我的办件 | 镜像状态列表 + 撤回 / 重试 / 重提 |
| `/apply-detail` | 办件详情 | `?id=`,快照回显 + 审批轨迹时间线 + 流程图 + 撤回/重提 |
| `/` | → `/hall` | 入口默认页 |
| 其他 | → `/hall` | 外部深链兜底,避免 no-match 白屏 |
## 皮肤包(`src/themes/`,参照 CMS 皮肤包方向)
SPA 版的皮肤机制,复刻 CMS《10-皮肤包契约.md》里**可移植的四条纪律**(模板绑定体不可移植,
见《CMS事项库与办事大厅方案V1.1.md》§3.1):
1. **包 = 目录 + 清单**:`src/themes/{name}/theme.json` + `theme.css`,`name` 必须等于目录名;
新增皮肤 = 新增目录,宿主零改动(Vite `import.meta.glob` 构建期收集)。
2. **能力声明式**:`params[]` 声明能调什么(`color` 可带 `cssVar`;`select`/`text` 落 `data-sh-*` 钩子);
运行时覆盖值按声明过滤(颜色查格式、select 查选项),非法值丢默然回落默认。
3. **令牌单一出口**:主题色只走 `var(--sh-*)`(底座在 `src/assets/styles/theme-base.css`),
换皮只改变量;列表布局这类结构变体由皮肤 CSS 命中 `data-sh-*`,宿主不判断皮肤名。
4. **默认包强制 + 整页回退**:`default` 缺失直接抛错;未注册的名字回退 `default`,
绝不出现"自己的 HTML 配别人的 CSS"。
内置两套:`default`(校园蓝)、`campus-green`(校园青绿,附带 `listLayout` 布局变体开关)。
坏包(`specVersion`/命名/参数不合规)整包丢弃并告警,不做部分加载。
## 构建与发布
push 到 `main` → gitea CI(`.gitea/workflows/build.yml`):`npm run build` → 打包
`svc-hall-hall-<sha8>.zip` → 推 Nexus raw → 通知 projman 建档(`app=svc-hall, front=hall`)。
线上部署由运维/管理员在 projman「前端发布」手动触发,push 不会自动上线。
**首次上线前**需在 projman 应用 `svc-hall` 下新增前端目标 `hall`(路径片段 `hall`),
并在配置管理里补 `env-config-hall.json`(`siteCode`、`theme`、`baseHost`、`ssoHost`),
再把 CMS 站点的 `svc_hall_base` 指向新前端(`https://<域名>/svc-hall/hall`)。详见
`docs/公共端上线与projman接入说明.md`。
## 契约纪律
- 前端**不得**直接调用流程中心(无 `flowHost`、无外部 `/api/flow/**`):撤回/进度/流程图一律走
本应用后端 `/api/apply/{id}/withdraw|progress|diagram.png`。票据、CORS、授权点都在后端一侧。
- 提交响应必须按 `flowEnabled` + `flowStatus` 分流(`src/views/Apply.vue` 有详细注释):
代发起是异步的,"提交成功那一刻 flowStatus 恒为 0",不能当成失败。
- 表单 schema 契约同后台前端(后端 `FormValidator` 强校验:属性白名单、`key` 命名、`maxSize` 单位 MB)。
+41
View File
@@ -0,0 +1,41 @@
import type { GeneratorConfig } from "@alova/wormhole";
export default {
generator: [
{
input: "http://localhost:8070/v2/api-docs",
// 文档平台类型
platform: "swagger",
// 生成代码的输出目录
output: "src/api",
// 响应数据的媒体类型
responseMediaType: "application/json",
// 请求体数据的媒体类型
bodyMediaType: "application/json",
// 生成的 API 版本
version: 1,
// 生成代码类型
type: "typescript",
// 全局 API 名称
global: "Apis",
splitImplementation: true,
implementation: "inline",
// API 处理函数
handleApi: apiDescriptor => {
// 过滤掉已废弃的 API
if (apiDescriptor.deprecated) {
return undefined;
}
const newOpId = apiDescriptor.summary?.trim() || apiDescriptor.operationId;
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
return {
...apiDescriptor,
operationId: newOpId, // 关键:改掉它
url: apiDescriptor.url.replace(/^\/{platform}\//, "/api/"),
};
},
},
],
} as GeneratorConfig;
+6
View File
@@ -0,0 +1,6 @@
//铃铛debug
window.conf = {
baseHost: "http://basedebug.bestsch.com",
ssoHost: "http://ssodebug.bestsch.com",
serviceHost: "http://basedebug.bestsch.com/QualityEvaluate",
};
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/public/,故为 public,
对应 projman 前端目标名 public(文件 /app/config/env-config-public.json)。
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
<script>
(function () {
try {
var seg = location.pathname.split("/").filter(Boolean);
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
var xhr = new XMLHttpRequest();
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
xhr.send(null);
if (xhr.status === 200) {
var conf = JSON.parse(xhr.responseText);
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
}
} catch (e) {
/* 拉取失败保持未设置,走同域推导兜底 */
}
})();
</script>
<title><%- title %></title>
<%- injectScript %>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+11279
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "svc-hall-hall",
"private": true,
"version": "0.0.0",
"nodeversion": "20.19.4",
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"dev:mock": "vite --mode mock",
"build": "vite build --mode base",
"build:base": "vite build --mode base",
"build:ld": "vite build --mode ld",
"build:prod": "vite build --mode prod",
"preview": "vite preview",
"api:gen": "alova gen -f"
},
"dependencies": {
"@open-file-viewer/core": "^0.1.43",
"@open-file-viewer/vue": "^0.1.43",
"@types/lodash": "^4.14.196",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"alova": "^3.4.0",
"axios": "^0.24.0",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"mockjs": "^1.1.0",
"pdfjs-dist": "^4.10.38",
"pinia": "^2.1.4",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"vite-plugin-mock": "2.9.6",
"vue": "^3.4.19",
"vue-router": "4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@alova/wormhole": "^1.5.0",
"@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",
"fast-glob": "^3.3.2",
"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": "^1.8.27"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* 公共办事大厅外壳(学生/教师/访客入口):
* 顶栏(站点名 + 在线办理/我的办件导航 + 当前用户)+ 内容区。
* 与后台前端(front/)刻意不同:不渲染平台模块菜单(app_module 是管理端契约),
* 只做两个入口——办事大厅、我的办件(详情/办理页从列表进入)。
*
* 皮肤:启动时按运行时配置(env-config 的 theme/themeParams)应用皮肤包;
* 未配置 → default;未注册 → default(整页回退,见 utils/theme.ts)。
*/
import { onMounted, ref } from "vue";
import { baseApi } from "@/api/module/base";
import config from "@/assets/config/host.config";
import { applyTheme, resolveTheme, type ThemeManifest } from "@/utils/theme";
const userName = ref("");
const theme = ref<ThemeManifest>(resolveTheme(null));
onMounted(async () => {
// 皮肤先应用(首屏观感),再拉用户信息——用户信息失败不影响办理页面使用
theme.value = applyTheme(config.theme, config.themeParams);
try {
const user: any = await baseApi.getUserInfo().send();
userName.value = user?.name || "";
} catch (e: any) {
// CAS 未登录时平台层会跳转登录页(error.message === "redirect"),此处不打扰用户
if (e?.message !== "redirect") {
console.warn("[public-hall] 获取用户信息失败", e);
}
}
});
</script>
<template>
<div class="sh-shell">
<header class="sh-header">
<div class="sh-brand">
办事大厅
<small>在线办理 · {{ theme.title }}</small>
</div>
<nav class="sh-nav">
<router-link to="/hall">办事大厅</router-link>
<router-link to="/my">我的办件</router-link>
</nav>
<span class="sh-user" v-if="userName">{{ userName }}</span>
</header>
<main class="sh-main">
<router-view :key="$route.fullPath" />
</main>
<footer class="sh-footer">云海平台 · 办事大厅(在线申请入口)</footer>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { SchoolTreeNode } from "@/types/svc";
/**
* 基础平台接口(Notice baseApi 平移):用户信息与已授权模块列表(菜单渲染数据源)。
* 模块清单在 UserBase 按本应用 appKey 配置(app_module),按登录人过滤下发。
*/
const BASE = "/api/base";
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
class BaseApi {
/** 当前登录用户信息 */
getUserInfo() {
return get<SchoolTreeNode>(`${BASE}/GetUserInfo`);
}
/** 当前用户已授权的模块列表(左侧菜单数据源) */
getUserMod() {
return get<SchoolTreeNode[]>(`${BASE}/GetUserMod`);
}
}
export const baseApi = new BaseApi();
+126
View File
@@ -0,0 +1,126 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
import config from "@/assets/config/host.config";
import type {
ApplySubmitRequest,
ApplyVo,
FlowProgressVo,
HallFilters,
HallItem,
PageResult,
} from "@/types/svc";
/**
* 公共办事大厅接口封装(全部为同源 /api/**,CAS 保护)。
*
* 纪律(2026-09-16 治理,务必遵守):
* 本文件里**不得出现任何 flow-center 地址**(无 flowHost、无直连外部 /api/flow/**)。
* 撤回/进度/流程图都是流程中心的能力,一律经办事大厅后端中转(后端用 YschFlowOpenApi 出站,
* 对端 appKey 验签 + 撤回按 submitterId 复核)。浏览器侧因此不需要跨应用票据,
* 也不存在"img 带不了签名"的问题——流程图就是一个同源 <img src>。
*/
const HALL = "/api/hall";
const APPLY = "/api/apply";
const FILE = "/api/file";
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
function postJSON<T = any>(url: string, data?: Record<string, any> | any[]): Method<T> {
return new Method<T>(
"POST",
alovaInstance,
url,
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
data ? JSON.stringify(data) : undefined
);
}
/** POST 表单(@RequestParam) */
function postForm<T = any>(url: string, data?: Record<string, any>): Method<T> {
const form = data
? Object.entries(data)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join("&")
: undefined;
return new Method<T>(
"POST",
alovaInstance,
url,
{ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" } },
form
);
}
class HallApi {
/** 已发布事项分页(关键词/分类/服务对象/部门筛选) */
items(params: Record<string, any>) {
return get<PageResult<HallItem>>(`${HALL}/items`, params);
}
/** 筛选项(服务对象/分类/部门 + 已发布计数) */
filters(siteCode: string) {
return get<HallFilters>(`${HALL}/filters`, { siteCode });
}
}
class ApplyApi {
/** 发起(后端落待提交行 + AFTER_COMMIT 异步代发起;出参见 flowEnabled 判断是否已接入) */
submit(data: ApplySubmitRequest) {
return postJSON<ApplyVo>(APPLY, data);
}
/** 待提交态重试(复用同一 bizId,流程中心 uk 幂等) */
retry(id: number) {
return postForm<ApplyVo>(`${APPLY}/${id}/retry`);
}
/** 一键重提(仅驳回/撤回态;同 bizId,流程中心 R4 重启推进) */
resubmit(id: number) {
return postForm<ApplyVo>(`${APPLY}/${id}/resubmit`);
}
/** 撤回(后端经 SDK 调流程中心托管件;仅提交人本人 + 审批中) */
withdraw(id: number) {
return postForm<void>(`${APPLY}/${id}/withdraw`);
}
/** 审批进度(结构化轨迹;与详情同可见性守卫) */
progress(id: number) {
return get<FlowProgressVo>(`${APPLY}/${id}/progress`);
}
/** 流程示意图地址(同源 <img src>;token 与字节都在后端流转)。
* img 标签不走 alova(无 baseUrl 拼接),必须显式带 serviceHost——
* 部署在 {ctx}/public/ 子路径时裸 /api 会落到网关根导致 404。 */
diagramUrl(id: number, mode = "dept") {
return `${config.serviceHost}${APPLY}/${id}/diagram.png?mode=${encodeURIComponent(mode)}`;
}
my(params?: Record<string, any>) {
return get<PageResult<ApplyVo>>(`${APPLY}/my`, params);
}
/** 发起页数据(事项要点 + 表单 schema) */
item(siteCode: string, itemCode: string) {
return get<Record<string, any>>(`${APPLY}/item`, { siteCode, itemCode });
}
detail(id: number) {
return get<ApplyVo>(`${APPLY}/${id}`);
}
}
class FileApi {
/** 上传(返回完整 URL 与附件行 id;绑定在提交动作完成) */
upload(file: File, type: "image" | "file", siteCode?: string) {
const form = new FormData();
form.append("file", file);
form.append("type", type);
if (siteCode) form.append("siteCode", siteCode);
return new Method<{ id: number; fileName: string; fileUrl: string; fileSize: number }>(
"POST",
alovaInstance,
`${FILE}/upload`,
{ headers: {} },
form
);
}
}
export const hallApi = new HallApi();
export const applyApi = new ApplyApi();
export const fileApi = new FileApi();
+91
View File
@@ -0,0 +1,91 @@
import { BaseConf } from "@/types/Basics";
/**
* 配置优先级(高 → 低):
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
*/
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地无、base/prod 为 /svc-hall);本地 dev/mock 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
// 本部署的站点标识(用户面没有站点选择器):由 env-config 下发(一个部署=一所学校);
// 留空时退回 URL query 与 localStorage,见 utils/site.ts
siteCode: "",
// 皮肤包名与参数覆盖(env-config 下发;未配置=default,未注册=整页回退 default)
theme: "",
themeParams: {} as Record<string, string>,
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isMock: false,
isDebug: env.DEV,
};
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
Object.keys(source).forEach(key => {
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
(target as any)[key] = source[key];
}
});
};
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
siteCode: env.VITE_SITE_CODE,
theme: env.VITE_THEME,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
isMock: env.VITE_IS_MOCK === "true",
});
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
// 运行时 JSON 只配部分资源库地址时未配的键沿用兜底值
const runtime = (window as any).__APP_CONFIG__;
applyConf(conf, runtime);
if (runtime && typeof runtime === "object" && runtime.relayConf) {
applyConf(conf.relayConf, runtime.relayConf);
}
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
if (ishttps) {
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;
+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

+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

+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;
}
}
File diff suppressed because one or more lines are too long
+117
View File
@@ -0,0 +1,117 @@
@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;
}
+156
View File
@@ -0,0 +1,156 @@
/* ============================================================================
* 公共办事大厅 · 设计令牌底座 + 公共观感
* 令牌默认值在这里兜底(与 default 皮肤同值),皮肤包只做覆盖:
* 好处是"皮肤包加载失败/未配置"时页面仍然完整可用(与 CMS 的 default fail-fast 同思路)。
* 纪律:所有主题色只走 var(--sh-*),页面样式里禁止再写死主题色。
* ==========================================================================*/
:root {
--sh-primary: #1a5cb0;
--sh-primary-dark: #12447f;
--sh-accent: #e8a33d;
--sh-page-bg: #f4f6fa;
--sh-radius: 8px;
--sh-text: #24292f;
--sh-text-weak: #6b7280;
--sh-border: #e6e9ef;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--sh-page-bg);
color: var(--sh-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
#app {
min-height: 100vh;
}
.sh-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 顶栏:校园标识 + 站点名 + 入口导航 */
.sh-header {
background: linear-gradient(90deg, var(--sh-primary), var(--sh-primary-dark));
color: #fff;
padding: 0 24px;
display: flex;
align-items: center;
height: 64px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.sh-header .sh-brand {
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
}
.sh-header .sh-brand small {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
margin-left: 8px;
}
.sh-header .sh-nav {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
}
.sh-header .sh-nav a {
color: #fff;
opacity: 0.82;
padding: 6px 14px;
border-radius: var(--sh-radius);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.sh-header .sh-nav a:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.14);
}
.sh-header .sh-nav a.router-link-active {
opacity: 1;
background: rgba(255, 255, 255, 0.22);
font-weight: 600;
}
.sh-user {
margin-left: 16px;
font-size: 13px;
opacity: 0.9;
}
.sh-main {
flex: 1;
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding: 20px 16px 40px;
box-sizing: border-box;
}
.sh-footer {
text-align: center;
color: var(--sh-text-weak);
font-size: 12px;
padding: 16px 0 24px;
}
/* 卡片与状态色:跨页面共用的小件 */
.sh-card {
background: #fff;
border-radius: var(--sh-radius);
border: 1px solid var(--sh-border);
padding: 16px 18px;
margin-bottom: 14px;
}
.sh-muted {
color: var(--sh-text-weak);
font-size: 13px;
}
.sh-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 12px;
line-height: 18px;
border: 1px solid var(--sh-border);
color: var(--sh-text-weak);
background: #fafbfc;
}
.sh-tag-primary {
color: var(--sh-primary);
border-color: var(--sh-primary);
background: rgba(26, 92, 176, 0.06);
}
.sh-tag-success {
color: #1f9254;
border-color: #1f9254;
background: rgba(31, 146, 84, 0.07);
}
.sh-tag-error {
color: #d03050;
border-color: #d03050;
background: rgba(208, 48, 80, 0.07);
}
.sh-tag-warning {
color: var(--sh-accent);
border-color: var(--sh-accent);
background: rgba(232, 163, 61, 0.08);
}
/* 主色按钮(view-ui 主题色不跟皮肤走,这里兜住主要动作按钮) */
.sh-btn-primary.ivu-btn-primary {
background: var(--sh-primary);
border-color: var(--sh-primary);
}
.sh-btn-primary.ivu-btn-primary:hover {
background: var(--sh-primary-dark);
border-color: var(--sh-primary-dark);
}
+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;
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
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 computed: typeof import('vue')['computed']
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 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']
}
// 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')
}
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
/**
* 动态表单渲染器(V2.0 Part B.3:schema → 控件,一期 11 类)。
* schemaJson = {"groups":[{title, fields:[{key,label,type,required,pattern,options,...}]}]}。
* v-model(formData) 为 {key:value} 对象;upload/image 字段值为 /api/file/upload 返回体数组
* [{id,fileName,fileUrl,fileSize,mimeType}],多选类(checkbox/multiselect)为 string[]。
*/
import { computed } from "vue";
import { Message } from "view-ui-plus";
import { fileApi } from "@/api/module/svc";
const props = defineProps<{ schemaJson?: string; formData: Record<string, any> }>();
const emit = defineEmits<{ (e: "update:formData", v: Record<string, any>): void }>();
interface Field {
key: string;
label: string;
type: string;
required?: boolean;
pattern?: string;
min?: number;
max?: number;
maxLength?: number;
tip?: string;
placeholder?: string;
options?: string[];
rows?: number;
defaultValue?: any;
accept?: string;
maxSize?: number;
maxFiles?: number;
}
const groups = computed<{ title: string; fields: Field[] }[]>(() => {
try {
const parsed = JSON.parse(props.schemaJson || "{}");
return (parsed.groups || []).map((g: any) => ({ title: g.title || "", fields: g.fields || [] }));
} catch {
return [];
}
});
function setValue(field: Field, value: any) {
emit("update:formData", { ...props.formData, [field.key]: value });
}
function ensureArray(field: Field): any[] {
const v = props.formData[field.key];
return Array.isArray(v) ? v : [];
}
/** upload/image 上传(maxSize MB、maxFiles 上限;maxFiles 缺省/0=不限;落库为 UploadVo 原样对象) */
async function uploadFile(field: Field, ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (field.maxSize && file.size > field.maxSize * 1024 * 1024) {
Message.error(`单个文件不得超过 ${field.maxSize}MB`);
return;
}
const list = ensureArray(field);
if (field.maxFiles && field.maxFiles > 0 && list.length >= field.maxFiles) {
Message.error(`附件数超过上限 ${field.maxFiles}`);
return;
}
try {
// image 走 image 通道:后端按 type 分目录(/upload/image/** 与 /upload/file/**)
const res: any = await fileApi.upload(file, field.type === "image" ? "image" : "file").send();
setValue(field, [...list, res]);
} catch (e: any) {
Message.error(e?.message || "上传失败");
}
}
</script>
<template>
<div class="dyn-form">
<div v-for="(group, gi) in groups" :key="gi" class="dyn-group">
<div v-if="group.title" class="dyn-group-title">{{ group.title }}</div>
<div v-for="field in group.fields" :key="field.key" class="dyn-field">
<label class="dyn-label">
<span v-if="field.required" class="dyn-required">*</span>
{{ field.label }}
</label>
<template v-if="field.type === 'text' || field.type === 'phone'">
<Input
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'textarea'">
<Input
type="textarea"
:rows="field.rows || 3"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'number'">
<InputNumber
:model-value="formData[field.key] ?? field.defaultValue ?? null"
:min="field.min"
:max="field.max"
:placeholder="field.placeholder || ''"
style="width: 220px"
@update:model-value="(v: number) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'date'">
<DatePicker
type="date"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
placeholder="选择日期"
style="width: 220px"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'select'">
<Select
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || '请选择'"
style="width: 260px"
@update:model-value="(v: string) => setValue(field, v)"
>
<Option v-for="opt in field.options || []" :key="opt" :value="opt">{{ opt }}</Option>
</Select>
</template>
<template v-else-if="field.type === 'multiselect'">
<Select
:model-value="ensureArray(field)"
multiple
:placeholder="field.placeholder || '请选择'"
style="width: 260px"
@update:model-value="(v: any[]) => setValue(field, v)"
>
<Option v-for="opt in field.options || []" :key="opt" :value="opt">{{ opt }}</Option>
</Select>
</template>
<template v-else-if="field.type === 'radio'">
<RadioGroup
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
@update:model-value="(v: string) => setValue(field, v)"
>
<Radio v-for="opt in field.options || []" :key="opt" :label="opt">{{ opt }}</Radio>
</RadioGroup>
</template>
<template v-else-if="field.type === 'checkbox'">
<CheckboxGroup
:model-value="ensureArray(field)"
@update:model-value="(v: any[]) => setValue(field, v)"
>
<Checkbox v-for="opt in field.options || []" :key="opt" :label="opt">{{ opt }}</Checkbox>
</CheckboxGroup>
</template>
<template v-else-if="field.type === 'upload' || field.type === 'image'">
<div class="dyn-upload">
<div v-for="(f, fi) in ensureArray(field)" :key="fi" class="dyn-file">
<img
v-if="field.type === 'image' && f.fileUrl"
:src="f.fileUrl"
class="dyn-thumb"
alt=""
/>
<a :href="f.fileUrl" target="_blank" rel="noopener">{{ f.fileName }}</a>
<a
class="dyn-file-remove"
@click.prevent="
setValue(
field,
ensureArray(field).filter((_, i) => i !== fi)
)
"
>移除</a>
</div>
<label class="dyn-file-btn">
<input
type="file"
style="display: none"
:accept="field.accept || (field.type === 'image' ? '.jpg,.jpeg,.png,.gif' : '')"
@change="uploadFile(field, $event)"
/>
<Button size="small" icon="ios-cloud-upload-outline">
{{ field.type === 'image' ? '上传图片' : '上传附件' }}
</Button>
</label>
</div>
</template>
<div v-if="field.tip" class="dyn-tip">{{ field.tip }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.dyn-group {
margin-bottom: 8px;
}
.dyn-group-title {
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid #eee;
margin-bottom: 12px;
}
.dyn-field {
margin-bottom: 14px;
}
.dyn-label {
display: block;
margin-bottom: 6px;
color: #515a6e;
}
.dyn-required {
color: #ed4014;
margin-right: 2px;
}
.dyn-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.dyn-file {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.dyn-file-remove {
color: #ed4014;
cursor: pointer;
font-size: 12px;
}
.dyn-thumb {
width: 64px;
height: 64px;
object-fit: cover;
vertical-align: middle;
margin-right: 8px;
border-radius: 4px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import utils from "@/utils";
import { createPinia } from "pinia";
// @ts-ignore
import ViewUIPlus from "view-ui-plus";
import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
// 设计令牌底座 + 公共观感(皮肤包只改变量,见 src/utils/theme.ts)
import "@/assets/styles/theme-base.css";
// 皮肤包样式(Vite 构建期收集:新增皮肤=新增目录,宿主零改动)
import.meta.glob("@/themes/*/theme.css", { eager: true });
// 规范 iconfont 图标本地实现(平台主题不含字体)
import "@/assets/styles/icons.css";
// 导入自定义指令
import directive from "@/utils/directive/index";
import "virtual:uno.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
import config from "@/assets/config/host.config";
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
app.config.globalProperties.$bus = mittBus;
directive(app);
import "virtual:svg-icons-register";
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";
/**
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
* 四个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 办件详情。
* 全部走 hash 路由(部署于 /{contextPath}/public/,hash 免服务端 rewrite)。
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
* {svc_hall_base}/#/apply?siteCode=&itemCode=)——拼成路径参数会 no-match 空白页。
*/
const routes: RouteRecordRaw[] = [
{
path: "/hall",
name: "hall",
component: () => import("@/views/Hall.vue"),
meta: { title: "办事大厅" },
},
{
path: "/apply",
name: "apply",
component: () => import("@/views/Apply.vue"),
meta: { title: "在线办理" },
},
{
path: "/my",
name: "my",
component: () => import("@/views/ApplyMy.vue"),
meta: { title: "我的办件" },
},
{
path: "/apply-detail",
name: "apply_detail",
component: () => import("@/views/ApplyDetail.vue"),
meta: { title: "办件详情" },
},
// 入口默认页 = 事项大厅
{ path: "/", redirect: "/hall" },
// 外部深链兜底(bizUrl / CMS 按钮可能拼成 /#/apply-detail/12 之类):回落大厅避免白屏
{ path: "/:pathMatch(.*)*", redirect: "/hall" },
];
export default routes;
+13
View File
@@ -0,0 +1,13 @@
/* 校园青绿——第二套皮肤:换令牌 + 换圆角 + 换列表布局(结构变体走 data-sh-* 钩子) */
:root[data-theme="campus-green"] {
--sh-primary: #0e8a6e;
--sh-primary-dark: #0a6a55;
--sh-accent: #d97706;
--sh-page-bg: #f2f8f5;
--sh-radius: 12px;
}
/* 列表布局变体:宿主只给 .sh-hall-grid 钩子,皮肤决定一行几张 */
:root[data-theme="campus-green"][data-sh-listLayout="row"] .sh-hall-grid {
grid-template-columns: 1fr;
}
+23
View File
@@ -0,0 +1,23 @@
{
"specVersion": 1,
"name": "campus-green",
"title": "校园青绿",
"version": "1.0.0",
"description": "第二套可切换皮肤:青绿主色 + 更宽松的圆角,用于验证皮肤包机制(新增皮肤=新增目录,宿主零改动)。",
"params": [
{ "key": "primary", "cssVar": "--sh-primary", "label": "主色", "type": "color", "default": "#0e8a6e" },
{ "key": "primaryDark", "cssVar": "--sh-primary-dark", "label": "主色加深", "type": "color", "default": "#0a6a55" },
{ "key": "accent", "cssVar": "--sh-accent", "label": "强调色", "type": "color", "default": "#d97706" },
{ "key": "pageBg", "cssVar": "--sh-page-bg", "label": "页面底色", "type": "color", "default": "#f2f8f5" },
{
"key": "listLayout",
"label": "事项列表布局",
"type": "select",
"default": "grid",
"options": [
{ "value": "grid", "label": "卡片网格(一行 3 张)" },
{ "value": "row", "label": "传统行列表" }
]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
/* 校园蓝(默认皮肤)——只声明设计令牌,不写布局规则(换皮不换骨) */
:root[data-theme="default"] {
--sh-primary: #1a5cb0;
--sh-primary-dark: #12447f;
--sh-accent: #e8a33d;
--sh-page-bg: #f4f6fa;
}
+13
View File
@@ -0,0 +1,13 @@
{
"specVersion": 1,
"name": "default",
"title": "校园蓝(默认)",
"version": "1.0.0",
"description": "公共办事大厅基准皮肤:顶栏 + 卡片列表,蓝白配色,与平台后台观感一致但更轻。",
"params": [
{ "key": "primary", "cssVar": "--sh-primary", "label": "主色", "type": "color", "default": "#1a5cb0" },
{ "key": "primaryDark", "cssVar": "--sh-primary-dark", "label": "主色加深", "type": "color", "default": "#12447f" },
{ "key": "accent", "cssVar": "--sh-accent", "label": "强调色", "type": "color", "default": "#e8a33d" },
{ "key": "pageBg", "cssVar": "--sh-page-bg", "label": "页面底色", "type": "color", "default": "#f4f6fa" }
]
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 公共办事大厅前端基础类型(本前端自持一份,不依赖后台前端 types)。
* 说明:`host.config.ts` 与 `utils/index.ts` 里原先 import 的 `@/types/Basics` 在本仓缺失
* (类型位置 import 被 esbuild 剥离才没炸),此处补齐并登记用户面专有的 conf 键。
*/
/** 通用对象 */
export interface IObj {
[key: string]: any;
}
/** 表格高度等设置项(view-ui 表格页通用形态) */
export interface UseTableSetting {
height?: number;
[key: string]: any;
}
/** 运行期配置(优先级见 host.config.ts) */
export interface BaseConf {
baseHost: string;
ssoHost: string;
serviceHost: string;
/** 本部署对应的 CMS 站点标识(用户面无站点选择器,见 utils/site.ts) */
siteCode: string;
/** 皮肤包名(未注册整页回退 default,见 utils/theme.ts) */
theme: string;
/** 皮肤参数覆盖(按皮肤声明的 params 过滤) */
themeParams: Record<string, string>;
relayConf: {
ziyuankuHost: string;
zhuanxiangHost: string;
};
isMock: boolean;
isDebug: boolean;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 办事大厅前端类型(对齐后端 SvcModels/OapiModels)。
*/
/** 学校组织树节点(BaseApi GetUserInfo/GetUserMod 通用形态) */
export interface SchoolTreeNode {
id?: number;
name?: string;
[key: string]: any;
}
// ===== 站点 =====
export interface SiteOption {
siteCode: string;
name: string;
schId: number;
}
// ===== 字典 =====
export interface ApplyVo {
id: number;
siteCode: string;
itemId: number;
itemCode: string;
itemTitle: string;
itemSnapshotJson?: string;
formVersion?: number;
formDataJson?: string;
attachmentsJson?: string;
submitterId: number;
submitterName: string;
flowGuid?: string;
flowInstanceId?: number;
flowStatus: number;
flowResultJson?: string;
submitTime?: string;
finishTime?: string;
createTime?: string;
}
/** 事项大厅列表行(后端复用 oapi ItemRow,见 HallController) */
export interface HallItem {
id: number;
itemCode: string;
title: string;
summary?: string;
coverImage?: string;
deptName?: string;
categoryName?: string;
handleMode?: number;
onlineFlag?: number;
publishTime?: string;
}
export interface HallFilterOption {
key?: string;
id?: number;
name: string;
count: number;
}
/** 筛选项(服务对象/分类/部门,各带已发布计数) */
export interface HallFilters {
objects?: HallFilterOption[];
categories?: HallFilterOption[];
depts?: HallFilterOption[];
}
/** 审批进度(后端复用 SDK FlowProgress,见 /api/apply/{id}/progress) */
export interface FlowProgressVo {
instanceId?: number;
bizType?: string;
bizId?: string;
bizTitle?: string;
status?: number;
currentNodes?: string;
submitterId?: number;
submitterName?: string;
submitTime?: string;
finishTime?: string;
steps?: FlowProgressStep[];
}
export interface FlowProgressStep {
nodeId?: string;
nodeName?: string;
label?: string;
active?: boolean;
assignees?: FlowProgressTask[];
}
export interface FlowProgressTask {
taskId?: number;
userId?: number;
userName?: string;
status?: number;
statusLabel?: string;
opinion?: string;
arriveTime?: string;
handleTime?: string;
edgeName?: string;
}
export interface PageResult<T> {
total: number;
list?: T[];
items?: T[];
}
+156
View File
@@ -0,0 +1,156 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import Utils from "..";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
injectHeaders?: (method: Method) => void;
/** 加载开始 */
onLoadingStart?: () => void;
/** 加载结束 */
onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/
onBizError?: (msg: string) => void;
/** http 错误提示(status !== 200)*/
onHttpError?: (msg: string) => void;
}
/** 和后端约定的通用响应格式 */
export interface BschResponse<T = any> {
code: number;
msg?: string;
data: T;
urlToRedirectTo?: string;
}
/* ============= 默认空实现 ============= */
const defHooks: Required<RequestHooks> = {
injectHeaders: () => {},
onLoadingStart: () => {},
onLoadingEnd: () => {},
onBizError: () => {},
onHttpError: () => {},
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
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);
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
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();
}
});
// 1. 用户注入票据/角色等头
injectHeaders(method);
method.config.credentials = "include";
// 2. 通用头
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
yschreferer: Utils.strToHexCharCode(window.location.href),
});
}
/* ---- 响应拦截 ---- */
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 },
});
}
+40
View File
@@ -0,0 +1,40 @@
// @/utils/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, {
injectHeaders(method) {
// currentRole: encodeURI(JSON.stringify({ userId, roleId, schId: schoolId })),
if (localStorage.getItem("currentRole") != null) {
method.config.headers["currentRole"] = localStorage.getItem("currentRole") as string;
}
},
/* 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 });
},
});
+19
View File
@@ -0,0 +1,19 @@
import { App } from "vue";
import realImg from "./real-img";
// 自动导入当前文件夹下的所有自定义指令(默认导出项)
export default (app: App<Element>) => {
realImg(app);
};
// const modules = import.meta.glob("../directive/**/**.ts");
// for (const path in modules) {
// // 排除当前文件
// if (path !== "../directive/index.ts") {
// modules[path]().then(mod => {
// mod.default(app);
// });
// }
// }
+41
View File
@@ -0,0 +1,41 @@
import type { App } from "vue";
export default (app: App<Element>): void => {
app.directive("real-img", {
async mounted(el, binding, vnode: any) {
console.log(el);
if (el.getAttribute("data-load") == null) el.classList.add("img-hide");
let imgURL = binding.value; //获取图片地址
if (imgURL) {
let exist = await imageIsExist(imgURL);
if (exist) {
el.setAttribute("src", imgURL);
el.setAttribute("data-load", "1");
}
}
el.classList.remove("img-hide");
},
});
};
/**
* 检测图片是否存在
* @param url
*/
let imageIsExist = function (url: string) {
return new Promise(resolve => {
var img: HTMLImageElement | null = new Image();
img.onload = function () {
if ((this as any).complete == true) {
resolve(true);
img = null;
}
};
img.onerror = function () {
resolve(false);
img = null;
};
img.src = url;
});
};
+347
View File
@@ -0,0 +1,347 @@
import { IObj, UseTableSetting } 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") date = date.replace(/\-/g, "/");
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 "";
}
/**
* 删除指定键
*/
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 = 112,
searchH = 52,
operatH = 48,
tabsH = 56,
pageH = 72,
paddingH = 8;
try {
//@ts-ignore 平台注入的全局对象,独立运行时不存在
const bschUtil = BschLoadUtil.getInstance();
if (!bschUtil.getIsSelf()) headerH = 56;
} catch (e) {
/* 独立运行:保留自身 header(56) + 面包屑(56) = 112 */
}
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);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
// import { BschIframeType, LightAppModal, IframePageParmas, RoleInfo, StateEnum } from "@/types";
import mitt, { Emitter } from "mitt";
type MittType<T = any> = {
reload: void; //刷新页面
setMenuPath: number; //选择左侧菜单
resetMenuPath: any[]; //重置设置左侧菜单
setNavPath: any[]; //设置面包屑
resetLayout: { identity: number; modId: number }; //重置页面菜单
};
// 类型
const emitter: Emitter<MittType> = mitt<MittType>();
// 导出
export default emitter;
+48
View File
@@ -0,0 +1,48 @@
import { ref } from "vue";
import config from "@/assets/config/host.config";
/**
* 公共办事大厅的"当前站点"(siteCode)——用户面没有站点选择器,来源按优先级:
* ① URL query(CMS 指南页"我要办理"按钮 / 门户深链:{base}/#/apply?siteCode=&itemCode=)
* ② 运行时配置 siteCode(env-config / VITE_SITE_CODE:一个部署对应一所学校,最常用)
* ③ localStorage 上次使用(同一浏览器内的连续办理)
* 三者皆空 → 页面提示"未配置站点,请从学校门户进入"(不猜也不硬编码)。
*
* 注意与后台前端(front/src/utils/site.ts)的区别:那里是"管理端可切换的站点上下文",
* 这里是"部署/深链决定的固定站点"。
*/
const currentSiteCode = ref<string>(localStorage.getItem("svchall.public.site") || "");
const lastItemCode = ref<string>(localStorage.getItem("svchall.public.item") || "");
export function useSite() {
/** 从 query / 运行时配置 / 本地记忆依次解析站点,命中即记住 */
function resolveSite(querySiteCode?: unknown): string {
const fromQuery = typeof querySiteCode === "string" ? querySiteCode.trim() : "";
const candidates = [fromQuery, String(config.siteCode || ""), currentSiteCode.value];
const hit = candidates.find(v => v && v.length > 0) || "";
if (hit && hit !== currentSiteCode.value) {
currentSiteCode.value = hit;
}
if (hit) {
localStorage.setItem("svchall.public.site", hit);
}
return hit;
}
function setSite(siteCode: string) {
currentSiteCode.value = siteCode;
if (siteCode) {
localStorage.setItem("svchall.public.site", siteCode);
}
}
/** 记住最近浏览的事项(大厅"继续办理"与返回体验用) */
function rememberItem(itemCode: string) {
lastItemCode.value = itemCode;
if (itemCode) {
localStorage.setItem("svchall.public.item", itemCode);
}
}
return { currentSiteCode, lastItemCode, resolveSite, setSite, rememberItem };
}
+136
View File
@@ -0,0 +1,136 @@
/* ============================================================================
* 公共办事大厅 · 皮肤包骨架(参照 Cms/docs/10-皮肤包契约.md 的方向,SPA 化落法)
*
* 与 CMS 皮肤包的差异(必须知道,否则会误以为能直接套用):
* CMS 皮肤包 = Thymeleaf 模板 + 服务端数据契约的绑定体,只有跑在 CMS 渲染链路里才有意义;
* 独立 SPA 无法"接入"CMS 皮肤包(详见 CMS事项库与办事大厅方案V1.1.md §3.1)。
* 这里复刻的是**可移植的四条纪律**,不是模板文件:
* ① 包 = 一个目录 + 一份清单(theme.json),扫目录即发现,不在宿主里写死皮肤;
* ② 能力声明式:包声明能调什么(params[]),宿主按声明过滤运行时覆盖值;
* ③ 单一出口聚合设计令牌:颜色一律落 CSS 变量,宿主只改变量,不碰样式规则;
* ④ 默认包强制存在 + 未注册包整页回退(避免"混合皮肤")。
*
* 目录约定:src/themes/{name}/theme.json + theme.css(目录名必须等于清单里的 name)。
* ==========================================================================*/
/** 皮肤包清单(theme.json);字段与 CMS 皮肤包同名同义,便于两侧对照维护 */
export interface ThemeManifest {
/** 契约版本,当前只支持 1(与 CMS 一致) */
specVersion: number;
/** 包名,必须等于目录名 */
name: string;
/** 展示名(管理端/文档用) */
title: string;
version: string;
description?: string;
/** 可调参数声明:宿主据此过滤站点/运行时覆盖值,未声明的键一律忽略 */
params?: ThemeParam[];
}
/** 可调参数:cssVar 仅 color 类型允许(与 CMS 同纪律:非颜色不进变量层) */
export interface ThemeParam {
key: string;
label: string;
type: "color" | "select" | "text";
cssVar?: string;
default?: string;
options?: { value: string; label: string }[];
}
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,31}$/;
const NAME_PATTERN = /^[a-z][a-z0-9-]{1,30}[a-z0-9]$/;
const COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export const DEFAULT_THEME = "default";
/** 包目录名 → 清单(Vite 构建期收集,新增皮肤只要加目录,宿主零改动) */
const manifests: Record<string, ThemeManifest> = (() => {
const out: Record<string, ThemeManifest> = {};
const modules = import.meta.glob<{ default: ThemeManifest }>("../themes/*/theme.json", { eager: true });
Object.keys(modules).forEach(path => {
const dirName = path.split("/")[2];
const manifest = modules[path].default;
if (!isValidManifest(manifest, dirName)) {
// 坏包整包拒绝(与 CMS ThemePackageValidator 同口径:不做部分加载)
console.warn(`[theme] 皮肤包 ${dirName} 校验不通过,已忽略`);
return;
}
out[manifest.name] = manifest;
});
return out;
})();
function isValidManifest(m: ThemeManifest, dirName: string): boolean {
if (!m || typeof m !== "object") return false;
if (m.specVersion !== 1) return false;
if (!m.name || !NAME_PATTERN.test(m.name) || m.name !== dirName) return false;
if (!m.title) return false;
const params = m.params || [];
const keys = new Set<string>();
for (const p of params) {
if (!p.key || !KEY_PATTERN.test(p.key) || keys.has(p.key)) return false;
keys.add(p.key);
if (p.type !== "color" && p.type !== "select" && p.type !== "text") return false;
if (p.cssVar && p.type !== "color") return false;
if (p.type === "select" && (!p.options || p.options.length === 0)) return false;
}
return true;
}
/** 全部已注册皮肤(管理端/文档列出用) */
export function listThemes(): ThemeManifest[] {
return Object.values(manifests);
}
/** 未注册/未配置一律回退 default(整页回退,绝不出现"自己的 HTML 配别人的 CSS") */
export function resolveTheme(name?: string | null): ThemeManifest {
const fallback = manifests[DEFAULT_THEME];
if (!fallback) {
throw new Error("[theme] 默认皮肤包 default 缺失:themes/default/theme.json 必须存在");
}
if (!name) return fallback;
const hit = manifests[name];
if (!hit) {
console.warn(`[theme] 皮肤包 ${name} 未注册,回退 ${DEFAULT_THEME}`);
return fallback;
}
return hit;
}
/**
* 应用皮肤:设置 data-theme(各包 CSS 按该属性生效)+ 按声明过滤运行时覆盖值后写 CSS 变量。
* 非颜色参数(select/text,无 cssVar)落成 html 上的 data-sh-{key} 属性,供皮肤 CSS 写结构变体
* (如列表 grid/row 布局),宿主只提供钩子类,不判断皮肤名。
* @param name 运行时配置的皮肤名(env-config 的 theme 键)
* @param overrides 覆盖值(env-config 的 themeParams 键,如 {primary:"#0e7a6a"})
*/
export function applyTheme(name?: string | null, overrides?: Record<string, any> | null): ThemeManifest {
const manifest = resolveTheme(name);
const root = document.documentElement;
root.setAttribute("data-theme", manifest.name);
(manifest.params || []).forEach(p => {
const raw = overrides ? overrides[p.key] : undefined;
const value = pickValidValue(p, raw) ?? p.default;
if (value === undefined || value === null) {
return;
}
if (p.cssVar) {
root.style.setProperty(p.cssVar, String(value));
} else {
root.setAttribute("data-sh-" + p.key, String(value));
}
});
return manifest;
}
/** 覆盖值合法化:颜色查格式、select 查选项、text 截断——非法值丢弃回落默认(同 CMS 口径) */
function pickValidValue(p: ThemeParam, raw: any): string | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
const value = String(raw);
if (p.type === "color") return COLOR_PATTERN.test(value) ? value : undefined;
if (p.type === "select") {
return (p.options || []).some(o => o.value === value) ? value : undefined;
}
return value.slice(0, 256);
}
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
/**
* 在线办理(发起页):CMS 指南页"我要办理"跳入 → /#/apply?siteCode=&itemCode=
*
* 提交反馈纪律(BP-R4-1 修复,务必保留语义):
* 后端代发起是"落库(PENDING=0) → AFTER_COMMIT 异步出站 → 回调推进 10"。所以**提交成功的那一刻
* flowStatus 必然是 0**,随后秒级翻转。原实现把 flowStatus===0 一律当"流程中心暂不可用",
* 于是明明提交成功也弹失败提示。现在按 flowEnabled 分流 + 短轮询收敛:
* flowEnabled=false → 流程中心未接入,真的不会翻转:如实告知"已保存为待提交";
* flowEnabled=true → 排队中:轮询详情等状态翻转(10=审批中),翻转即报成功;
* 超时未翻转才提示"已保存为待提交,可在我的办件重试"(此时多半真失败了)。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
import DynamicForm from "@/components/DynamicForm.vue";
const route = useRoute();
const router = useRouter();
const { resolveSite, rememberItem } = useSite();
/** 轮询参数:代发起 + 回调一般在 1s 内完成,给到 ~9s 足够,避免用户盯着"待提交"发懵 */
const POLL_TIMES = 12;
const POLL_INTERVAL_MS = 750;
const loading = ref(false);
const submitting = ref(false);
const item = ref<Record<string, any> | null>(null);
const loadError = ref("");
const siteCode = ref("");
const formData = reactive<Record<string, any>>({});
const schemaJson = computed(() => (item.value?.schemaJson as string) || "");
async function load() {
siteCode.value = resolveSite(route.query.siteCode);
const itemCode = String(route.query.itemCode || "");
if (!siteCode.value || !itemCode) {
loadError.value = "缺少 siteCode/itemCode 参数";
return;
}
rememberItem(itemCode);
loading.value = true;
try {
item.value = (await applyApi.item(siteCode.value, itemCode).send()) as any;
loadError.value = "";
} catch (e: any) {
// 区分加载失败与业务性不可办理:网络/服务异常不当成"事项不可办理"
loadError.value = String(e?.message || e || "网络或服务异常");
} finally {
loading.value = false;
}
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
/** 轮询办件状态直到离开 PENDING;超时返回最后一次看到的办件(可能仍是待提交) */
async function awaitFlowStart(applyId: number, first: any) {
let latest = first;
for (let i = 0; i < POLL_TIMES; i++) {
if (latest && latest.flowStatus !== 0 && latest.flowStatus !== undefined && latest.flowStatus !== null) {
return latest;
}
await sleep(POLL_INTERVAL_MS);
try {
latest = (await applyApi.detail(applyId).send()) as any;
} catch {
// 轮询期间的偶发失败不打断流程,继续下一轮
}
}
return latest;
}
async function submit() {
if (!item.value) return;
submitting.value = true;
try {
const apply: any = await applyApi
.submit({
siteCode: siteCode.value,
itemCode: String(route.query.itemCode),
formData: Object.keys(formData).length ? JSON.stringify(formData) : undefined,
})
.send();
// ① 流程中心未接入:不会翻转,如实告知(不说"暂不可用",因为压根没接)
if (apply.flowEnabled === false) {
Message.warning("流程中心未接入,申请已保存为待提交;请联系管理员开通后重试");
} else if (apply.flowStatus === 0) {
// ② 排队中:等状态翻转(成功即成功提示,不再误报)
const latest = await awaitFlowStart(apply.id, apply);
if (latest && latest.flowStatus && latest.flowStatus !== 0) {
Message.success("已提交,进入审批流程");
} else {
Message.warning("申请已保存为待提交:流程中心响应较慢,可在「我的办件」中重试");
}
} else {
Message.success("已提交,进入审批流程");
}
router.replace(`/apply-detail?id=${apply.id}`);
} finally {
submitting.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover v-if="item">
<template #title>在线办理 · {{ item.title }}</template>
<div class="summary" v-if="item.summary || item.deptName || item.handleTimeLimit">
<div v-if="item.summary">{{ item.summary }}</div>
<div class="meta">
<span v-if="item.deptName">办理部门:{{ item.deptName }}</span>
<span v-if="item.handleTimeLimit">办理时限:{{ item.handleTimeLimit }}</span>
<span v-if="item.contactPhone">咨询电话:{{ item.contactPhone }}</span>
</div>
</div>
<Divider v-if="schemaJson" orientation="left">申请表单</Divider>
<DynamicForm v-if="schemaJson" :schema-json="schemaJson" v-model:form-data="formData" />
<div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div>
<div class="actions">
<Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit">
提交申请
</Button>
<Button @click="router.push('/my')">我的办件</Button>
<Button @click="router.push('/hall')">返回大厅</Button>
</div>
</Card>
<Card :bordered="false" dis-hover v-else>
<div class="state-tip">
<template v-if="loading">加载中…</template>
<template v-else-if="loadError">
事项加载失败:{{ loadError }}
</template>
<template v-else>事项不可办理(未发布或未开通在线办理)</template>
</div>
</Card>
</div>
</template>
<style scoped>
.summary {
padding-bottom: 12px;
}
.meta {
margin-top: 8px;
display: flex;
gap: 16px;
color: var(--sh-text-weak);
font-size: 13px;
flex-wrap: wrap;
}
.no-form {
color: var(--sh-text-weak);
padding: 12px 0;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
.state-tip {
text-align: center;
padding: 40px;
color: var(--sh-text-weak);
}
</style>
+373
View File
@@ -0,0 +1,373 @@
<script setup lang="ts">
/**
* 办件详情:快照回显 + 镜像状态 + 审批轨迹时间线 + 流程图 + 撤回/重提。
* 流程中心的三个能力全部走本应用后端(不再有 flowHost 直连):
* 撤回 /api/apply/{id}/withdraw、进度 /api/apply/{id}/progress、流程图 /api/apply/{id}/diagram.png。
*/
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import type { ApplyVo, FlowProgressVo } from "@/types/svc";
const route = useRoute();
const router = useRouter();
const apply = ref<ApplyVo | null>(null);
const progress = ref<FlowProgressVo | null>(null);
const acting = ref(false);
const diagramVisible = ref(false);
const diagramUrl = ref("");
const STATUS: Record<number, { text: string; cls: string }> = {
0: { text: "待提交", cls: "sh-tag" },
10: { text: "审批中", cls: "sh-tag sh-tag-primary" },
20: { text: "已完成", cls: "sh-tag sh-tag-success" },
30: { text: "已拒绝", cls: "sh-tag sh-tag-error" },
40: { text: "已撤回", cls: "sh-tag sh-tag-warning" },
};
const SNAPSHOT_LABEL: Record<string, string> = {
itemTitle: "事项名称",
summary: "事项说明",
deptName: "办理部门",
handleTimeLimit: "办理时限",
contactPhone: "咨询电话",
};
const snapshot = computed<Record<string, any>>(() => parse(apply.value?.itemSnapshotJson));
const formData = computed<Record<string, any>>(() => parse(apply.value?.formDataJson));
const result = computed<Record<string, any>>(() => parse(apply.value?.flowResultJson));
function parse(json?: string): Record<string, any> {
try {
return JSON.parse(json || "{}");
} catch {
return {};
}
}
/** 字段 key → 表单里的中文标签(schema 里的 label 优先,缺失回落 key 名) */
const fieldLabels = ref<Record<string, string>>({});
async function load() {
const id = Number(route.query.id || 0);
if (!id) return;
apply.value = (await applyApi.detail(id).send()) as any;
loadSchemaLabels();
try {
progress.value = (await applyApi.progress(id).send()) as any;
} catch (e: any) {
// 进度需要可见性放行;查看他人办件时可能被拒——不打断详情展示
console.warn("[public-hall] 加载审批进度失败", e?.message || e);
}
}
/** 事项表单 schema(发起页同源接口)只用于把 key 显示成中文,失败则显示 key 本身 */
async function loadSchemaLabels() {
const siteCode = String(route.query.siteCode || apply.value?.siteCode || "");
const itemCode = apply.value?.itemCode;
if (!siteCode || !itemCode) return;
try {
const item: any = await applyApi.item(siteCode, itemCode).send();
const schema = JSON.parse(item?.schemaJson || "{}");
const labels: Record<string, string> = {};
(schema.groups || []).forEach((g: any) => {
(g.fields || []).forEach((f: any) => {
if (f.key) labels[f.key] = f.label || f.key;
});
});
fieldLabels.value = labels;
} catch {
// 忽略:标签只是展示优化
}
}
function displayValue(v: any): string {
if (v === null || v === undefined || v === "") return "—";
if (Array.isArray(v)) {
return v.map((f: any) => f?.fileName || f?.fileUrl || String(f)).join("、");
}
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
async function withdraw() {
if (!apply.value) return;
Modal.confirm({
title: "撤回确认",
content: "确认撤回该办件?撤回后可修改后重新提交,审批轨迹跨轮保留。",
onOk: async () => {
acting.value = true;
try {
await applyApi.withdraw(apply.value!.id).send();
Message.success("已撤回,可重新提交");
load();
} catch (e: any) {
Message.error(e?.message || "撤回失败");
} finally {
acting.value = false;
}
},
});
}
async function resubmit() {
if (!apply.value) return;
Modal.confirm({
title: "重新提交确认",
content: "将以原填报内容重新提交,审批轨迹跨轮连续保留。确认继续?",
onOk: async () => {
acting.value = true;
try {
await applyApi.resubmit(apply.value!.id).send();
Message.success("已重新提交,进入审批流程");
load();
} catch (e: any) {
Message.error(e?.message || "重新提交失败");
} finally {
acting.value = false;
}
},
});
}
async function retry() {
if (!apply.value) return;
acting.value = true;
try {
await applyApi.retry(apply.value.id).send();
Message.success("已重新提交");
load();
} catch (e: any) {
Message.error(e?.message || "重试失败");
} finally {
acting.value = false;
}
}
/** 流程图:同源图片(后端已完成"换 token + 取图",前端只放 <img src>) */
function openDiagram() {
if (!apply.value?.flowInstanceId) {
Message.warning("该办件尚未进入流程,暂无流程图");
return;
}
diagramUrl.value = applyApi.diagramUrl(apply.value.id, "dept");
diagramVisible.value = true;
}
const STEP_STATUS_CLASS: Record<number, string> = {
0: "step-pending",
1: "step-done",
2: "step-reject",
3: "step-invalid",
4: "step-return",
5: "step-done",
};
onMounted(load);
</script>
<template>
<div class="page" v-if="apply">
<div class="sh-card">
<div class="head">
<span class="title">办件详情 #{{ apply.id }}</span>
<span :class="STATUS[apply.flowStatus]?.cls || 'sh-tag'">
{{ STATUS[apply.flowStatus]?.text || apply.flowStatus }}
</span>
</div>
<div class="kv" v-for="(label, key) in SNAPSHOT_LABEL" :key="key" v-show="snapshot[key]">
<span class="k">{{ label }}</span>
<span class="v">{{ snapshot[key] }}</span>
</div>
<div class="kv">
<span class="k">提交人</span>
<span class="v">{{ apply.submitterName }}</span>
</div>
<div class="kv">
<span class="k">提交时间</span>
<span class="v">{{ apply.submitTime || "—" }}</span>
</div>
<div class="kv" v-if="apply.finishTime">
<span class="k">办结时间</span>
<span class="v">{{ apply.finishTime }}</span>
</div>
</div>
<div class="sh-card" v-if="apply.formDataJson">
<div class="section-title">申请内容</div>
<div class="kv" v-for="(v, k) in formData" :key="String(k)">
<span class="k">{{ fieldLabels[String(k)] || k }}</span>
<span class="v">{{ displayValue(v) }}</span>
</div>
</div>
<div class="sh-card" v-if="result.result || result.opinion">
<div class="section-title">审批结果</div>
<p>结果:{{ result.result || "—" }}</p>
<p v-if="result.opinion">意见:{{ result.opinion }}</p>
</div>
<div class="sh-card">
<div class="section-title">审批进度</div>
<div v-if="!progress || !progress.steps || !progress.steps.length" class="sh-muted">
{{ apply.flowInstanceId ? "加载中或暂无可展示的轨迹" : "尚未进入审批流程(可在「我的办件」重试提交)" }}
</div>
<div class="timeline" v-else>
<div class="step" v-for="(step, idx) in progress.steps" :key="idx">
<div class="step-dot" :class="{ active: step.active }"></div>
<div class="step-body">
<div class="step-head">
<span class="step-name">{{ step.nodeName || step.label }}</span>
<span class="sh-tag" v-if="step.label">{{ step.label }}</span>
<span class="sh-tag sh-tag-primary" v-if="step.active">办理中</span>
</div>
<div class="assignee" v-for="(a, ai) in step.assignees" :key="ai"
:class="STEP_STATUS_CLASS[a.status || 0]">
<span class="assignee-name">{{ a.userName || "—" }}</span>
<span class="sh-muted">{{ a.statusLabel || "" }}</span>
<span class="sh-muted" v-if="a.handleTime">办理于 {{ a.handleTime }}</span>
<span class="sh-muted" v-else-if="a.arriveTime">到达 {{ a.arriveTime }}</span>
<div class="opinion" v-if="a.opinion">意见:{{ a.opinion }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="sh-card actions">
<Button @click="router.push('/my')">返回我的办件</Button>
<Button @click="router.push('/hall')">办事大厅</Button>
<Button v-if="apply.flowInstanceId" @click="openDiagram">查看流程图</Button>
<Button v-if="apply.flowStatus === 0" :loading="acting" @click="retry">重试提交</Button>
<Button v-if="apply.flowStatus === 10" :loading="acting" @click="withdraw">撤回</Button>
<Button v-if="apply.flowStatus === 30 || apply.flowStatus === 40" class="sh-btn-primary"
type="primary" :loading="acting" @click="resubmit">重新提交</Button>
</div>
<Modal v-model="diagramVisible" title="流程图(含当前办理状态)" width="900" footer-hide>
<div class="diagram-wrap">
<img :src="diagramUrl" alt="流程图" />
</div>
</Modal>
</div>
<div class="sh-card" v-else>加载中…</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.title {
font-size: 17px;
font-weight: 600;
}
.section-title {
font-weight: 600;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid var(--sh-primary);
}
.kv {
display: flex;
padding: 6px 0;
border-bottom: 1px dashed var(--sh-border);
font-size: 14px;
}
.k {
width: 120px;
color: var(--sh-text-weak);
flex-shrink: 0;
}
.v {
flex: 1;
word-break: break-all;
}
.timeline {
padding-left: 4px;
}
.step {
display: flex;
gap: 10px;
padding-bottom: 14px;
position: relative;
}
.step:not(:last-child)::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 0;
width: 1px;
background: var(--sh-border);
}
.step-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: #c8ced8;
margin-top: 4px;
flex-shrink: 0;
z-index: 1;
}
.step-dot.active {
background: var(--sh-primary);
box-shadow: 0 0 0 3px rgba(26, 92, 176, 0.15);
}
.step-body {
flex: 1;
}
.step-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.step-name {
font-weight: 600;
}
.assignee {
font-size: 13px;
padding: 4px 8px;
border-radius: 4px;
background: #fafbfc;
margin-bottom: 4px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.assignee-name {
font-weight: 500;
}
.step-done .assignee-name {
color: #1f9254;
}
.step-reject .assignee-name {
color: #d03050;
}
.step-invalid {
opacity: 0.55;
text-decoration: line-through;
}
.opinion {
width: 100%;
color: var(--sh-text-weak);
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.diagram-wrap {
max-height: 70vh;
overflow: auto;
text-align: center;
}
.diagram-wrap img {
max-width: 100%;
}
</style>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
/**
* 我的办件:镜像状态列表 + 撤回/重试/重提入口。
* 撤回走本应用后端 /api/apply/{id}/withdraw(不再直调 flow-center:跨应用票据与 CORS 都不再需要,
* "谁能撤"由后端 + 流程中心按 submitterId 双重把关)。
*/
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import type { ApplyVo } from "@/types/svc";
const router = useRouter();
const loading = ref(false);
const list = ref<ApplyVo[]>([]);
const total = ref(0);
const page = ref(1);
const flowStatus = ref<number | undefined>(undefined);
const actingId = ref<number>(0);
const STATUS: Record<number, { text: string; cls: string }> = {
0: { text: "待提交", cls: "sh-tag" },
10: { text: "审批中", cls: "sh-tag sh-tag-primary" },
20: { text: "已完成", cls: "sh-tag sh-tag-success" },
30: { text: "已拒绝", cls: "sh-tag sh-tag-error" },
40: { text: "已撤回", cls: "sh-tag sh-tag-warning" },
};
async function load() {
loading.value = true;
try {
const res: any = await applyApi
.my({ flowStatus: flowStatus.value, page: page.value, pageSize: 10 })
.send();
total.value = res.total || 0;
list.value = res.list || [];
} finally {
loading.value = false;
}
}
/** 待提交态重试(后端复用同一 bizId 再次代发起,流程中心 uk 幂等) */
async function retry(row: ApplyVo) {
actingId.value = row.id;
try {
await applyApi.retry(row.id).send();
Message.success("已重新提交");
load();
} catch (e: any) {
Message.error(e?.message || "重试失败");
} finally {
actingId.value = 0;
}
}
/** 撤回:后端经 SDK 调流程中心托管件;仅提交人本人 + 审批中(失败原因原样透传) */
async function withdraw(row: ApplyVo) {
Modal.confirm({
title: "撤回确认",
content: `确认撤回「${row.itemTitle}」?撤回后可修改后重新提交,审批轨迹跨轮保留。`,
onOk: async () => {
actingId.value = row.id;
try {
await applyApi.withdraw(row.id).send();
Message.success("已撤回,可重新提交");
load();
} catch (e: any) {
// 典型原因:"已有任务被办理,不能撤回"
Message.error(e?.message || "撤回失败");
} finally {
actingId.value = 0;
}
},
});
}
/** 一键重提(驳回/撤回态;同 bizId,流程中心重启推进,轨迹跨轮连续) */
async function resubmit(row: ApplyVo) {
Modal.confirm({
title: "重新提交确认",
content: `将以原填报内容重新提交「${row.itemTitle}」,审批轨迹跨轮连续保留。确认继续?`,
onOk: async () => {
actingId.value = row.id;
try {
await applyApi.resubmit(row.id).send();
Message.success("已重新提交,进入审批流程");
load();
} catch (e: any) {
Message.error(e?.message || "重新提交失败");
} finally {
actingId.value = 0;
}
},
});
}
onMounted(load);
</script>
<template>
<div class="page">
<div class="sh-card bar">
<Select v-model="flowStatus" clearable placeholder="全部状态" style="width: 150px"
@on-change="() => { page = 1; load(); }">
<Option v-for="(v, k) in STATUS" :key="k" :value="Number(k)">{{ v.text }}</Option>
</Select>
<span class="sh-muted">共 {{ total }} 条</span>
<Button class="sh-btn-primary" type="primary" size="small" @click="router.push('/hall')">
去办事大厅
</Button>
</div>
<div class="sh-card" v-for="row in list" :key="row.id">
<div class="row-head">
<span class="row-title" @click="router.push(`/apply-detail?id=${row.id}`)">
{{ row.itemTitle }}
</span>
<span :class="STATUS[row.flowStatus]?.cls || 'sh-tag'">
{{ STATUS[row.flowStatus]?.text || row.flowStatus }}
</span>
</div>
<div class="row-meta sh-muted">
<span>办件号 {{ row.id }}</span>
<span>提交时间 {{ row.submitTime || "—" }}</span>
<span v-if="row.flowInstanceId">流程实例 {{ row.flowInstanceId }}</span>
</div>
<div class="row-actions">
<Button size="small" type="text" @click="router.push(`/apply-detail?id=${row.id}`)">详情</Button>
<Button size="small" type="text" v-if="row.flowStatus === 0"
:loading="actingId === row.id" @click="retry(row)">重试提交</Button>
<Button size="small" type="text" v-if="row.flowStatus === 10"
:loading="actingId === row.id" @click="withdraw(row)">撤回</Button>
<Button size="small" type="text" v-if="row.flowStatus === 30 || row.flowStatus === 40"
:loading="actingId === row.id" @click="resubmit(row)">重新提交</Button>
</div>
</div>
<div class="sh-card empty" v-if="!list.length && !loading">还没有办件,去办事大厅选一个事项开始吧</div>
<Page class="pager" v-if="total > 10" :total="total" :current="page" :page-size="10" show-total
@on-change="(p: number) => { page = p; load(); }" />
<Spin v-if="loading" fix></Spin>
</div>
</template>
<style scoped>
.bar {
display: flex;
align-items: center;
gap: 12px;
}
.row-head {
display: flex;
align-items: center;
gap: 10px;
}
.row-title {
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.row-title:hover {
color: var(--sh-primary);
}
.row-meta {
margin-top: 6px;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.row-actions {
margin-top: 8px;
text-align: right;
}
.empty {
text-align: center;
color: var(--sh-text-weak);
padding: 40px 16px;
}
.pager {
margin-top: 14px;
text-align: right;
}
</style>
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
/**
* 办事大厅(事项大厅):学生/教师/访客的办理入口。
* 数据源 /api/hall/items(后端复用门户 oapi 的已发布事项口径),支持关键词 + 服务对象/分类/部门筛选。
* 列表布局由皮肤包决定(宿主只给 .sh-hall-grid 钩子,见 src/themes/**)。
*/
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { hallApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
import type { HallFilterOption, HallItem } from "@/types/svc";
const route = useRoute();
const router = useRouter();
const { resolveSite } = useSite();
const loading = ref(false);
const list = ref<HallItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 9;
const siteCode = ref("");
const error = ref("");
const query = reactive({
keyword: "",
objectKey: "",
categoryKey: "",
deptGroupId: undefined as number | undefined,
});
const filters = reactive<{
objects: HallFilterOption[];
categories: HallFilterOption[];
depts: HallFilterOption[];
}>({ objects: [], categories: [], depts: [] });
async function load() {
if (!siteCode.value) {
error.value = "未配置站点:请从学校门户的办事指南页进入,或联系管理员配置站点标识";
return;
}
loading.value = true;
error.value = "";
try {
const res: any = await hallApi
.items({
siteCode: siteCode.value,
keyword: query.keyword || undefined,
objectKey: query.objectKey || undefined,
categoryKey: query.categoryKey || undefined,
deptGroupId: query.deptGroupId,
page: page.value,
pageSize,
})
.send();
total.value = res.total || 0;
list.value = res.items || [];
} catch (e: any) {
error.value = String(e?.message || e || "事项加载失败,请稍后重试");
} finally {
loading.value = false;
}
}
async function loadFilters() {
if (!siteCode.value) return;
try {
const res: any = await hallApi.filters(siteCode.value).send();
filters.objects = res.objects || [];
filters.categories = res.categories || [];
filters.depts = res.depts || [];
} catch {
// 筛选项失败不阻塞列表(大厅仍可用)
}
}
function pick(key: "objectKey" | "categoryKey" | "deptGroupId", value: any) {
// 同一维度再点一次=取消筛选
(query as any)[key] = (query as any)[key] === value ? (key === "deptGroupId" ? undefined : "") : value;
page.value = 1;
load();
}
function search() {
page.value = 1;
load();
}
/** 进入在线办理(携带 siteCode/itemCode,与 CMS 指南页按钮同一形态) */
function apply(item: HallItem) {
router.push({
path: "/apply",
query: { siteCode: siteCode.value, itemCode: item.itemCode, title: item.title },
});
}
onMounted(async () => {
siteCode.value = resolveSite(route.query.siteCode);
await loadFilters();
await load();
});
</script>
<template>
<div class="hall">
<div class="sh-card search-bar">
<Input v-model="query.keyword" placeholder="搜索事项名称 / 办理说明" clearable
@on-enter="search" @on-clear="search" style="max-width: 380px" />
<Button class="sh-btn-primary" type="primary" @click="search">搜索</Button>
<span class="sh-muted">共 {{ total }} 个可办理事项</span>
</div>
<div class="sh-card filter-bar" v-if="filters.objects.length || filters.categories.length || filters.depts.length">
<div class="filter-row" v-if="filters.objects.length">
<span class="filter-label">服务对象</span>
<span v-for="f in filters.objects" :key="String(f.key)" class="filter-chip"
:class="{ active: query.objectKey === f.key }" @click="pick('objectKey', f.key)">
{{ f.name }}({{ f.count }})
</span>
</div>
<div class="filter-row" v-if="filters.categories.length">
<span class="filter-label">事项分类</span>
<span v-for="f in filters.categories" :key="String(f.key)" class="filter-chip"
:class="{ active: query.categoryKey === f.key }" @click="pick('categoryKey', f.key)">
{{ f.name }}({{ f.count }})
</span>
</div>
<div class="filter-row" v-if="filters.depts.length">
<span class="filter-label">办理部门</span>
<span v-for="f in filters.depts" :key="String(f.id)" class="filter-chip"
:class="{ active: query.deptGroupId === f.id }" @click="pick('deptGroupId', f.id)">
{{ f.name }}({{ f.count }})
</span>
</div>
</div>
<Spin v-if="loading" fix></Spin>
<div class="sh-hall-grid" v-if="list.length">
<div class="item-card" v-for="item in list" :key="item.id">
<div class="item-title">{{ item.title }}</div>
<div class="item-summary sh-muted">{{ item.summary || "暂无事项说明" }}</div>
<div class="item-meta">
<span class="sh-tag" v-if="item.categoryName">{{ item.categoryName }}</span>
<span class="sh-tag" v-if="item.deptName">{{ item.deptName }}</span>
</div>
<div class="item-actions">
<Button size="small" class="sh-btn-primary" type="primary" @click="apply(item)">
在线办理
</Button>
</div>
</div>
</div>
<div class="sh-card empty" v-else-if="!loading">
<template v-if="error">{{ error }}</template>
<template v-else>暂无可办理事项(请确认事项已发布并开通在线办理)</template>
</div>
<Page class="pager" v-if="total > pageSize" :total="total" :current="page" :page-size="pageSize"
show-total @on-change="(p: number) => { page = p; load(); }" />
</div>
</template>
<style scoped>
.search-bar {
display: flex;
align-items: center;
gap: 10px;
}
.filter-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.filter-label {
color: var(--sh-text-weak);
font-size: 13px;
min-width: 60px;
}
.filter-chip {
cursor: pointer;
padding: 2px 10px;
border-radius: 12px;
font-size: 13px;
border: 1px solid var(--sh-border);
background: #fff;
transition: all 0.15s;
}
.filter-chip:hover {
border-color: var(--sh-primary);
color: var(--sh-primary);
}
.filter-chip.active {
border-color: var(--sh-primary);
color: #fff;
background: var(--sh-primary);
}
/* 默认皮肤:一行 3 张卡片;皮肤包可用 data-sh-listLayout 覆盖(换皮不换骨) */
.sh-hall-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 900px) {
.sh-hall-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 620px) {
.sh-hall-grid {
grid-template-columns: 1fr;
}
}
.item-card {
background: #fff;
border: 1px solid var(--sh-border);
border-radius: var(--sh-radius);
padding: 16px;
display: flex;
flex-direction: column;
min-height: 168px;
transition: box-shadow 0.2s, transform 0.2s;
}
.item-card:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.07);
transform: translateY(-2px);
}
.item-title {
font-size: 16px;
font-weight: 600;
line-height: 24px;
margin-bottom: 8px;
}
.item-summary {
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-meta {
margin: 10px 0 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.item-actions {
text-align: right;
}
.empty {
text-align: center;
color: var(--sh-text-weak);
padding: 48px 16px;
}
.pager {
margin-top: 14px;
text-align: right;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BASE_HOST: string;
readonly VITE_SSO_HOST: string;
readonly VITE_SERVICE_HOST: string;
/** 本部署站点标识(本地开发用;线上由 env-config 的 siteCode 下发) */
readonly VITE_SITE_CODE: string;
/** 皮肤包名(本地开发用;线上由 env-config 的 theme 下发) */
readonly VITE_THEME: 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"]
},
"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"]
}
+149
View File
@@ -0,0 +1,149 @@
import { ConfigEnv, UserConfig, defineConfig } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
import vue from "@vitejs/plugin-vue";
import path, { resolve } from "path";
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
import { viteMockServe } from "vite-plugin-mock";
import ViteAutoImport from "unplugin-auto-import/vite";
import UnoCSS from "@unocss/vite";
import presetUno from "@unocss/preset-uno";
import presetRemToPx from "@unocss/preset-rem-to-px";
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
// 构建产物直出 dist/(env-config 模式,与 Cms/UserBase 同风格):CI zip 打包取 dist 根,
// 不再有 dist/base 中间层(此前 outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/
// 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」);dev(serve) 不写产物,
// 两者共用该值无冲突
const outDir = "dist";
return {
plugins: [
vue(),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
symbolId: "icon-[dir]-[name]", // symbol的id
inject: "body-last", // 插入的位置
customDomId: "__svg__icons__dom__", // svg的id
}),
viteMockServe({
mockPath: "./src/assets/mock",
// 仅 --mode mock 时启用 mock 中间件;dev/联调/正式均走真实后端
localEnabled: mode === "mock",
}),
ViteAutoImport({
imports: [
"vue",
"vue-router",
{
"view-ui-plus": ["Button", "Table", "Modal", "Message", "Form", "FormItem", "Input", "Select"],
},
{
from: "alova/client",
imports: ["usePagination", "useRequest", "useWatcher", "useForm"],
},
],
dts: "./src/auto-imports.d.ts",
dirs: ["src/api"], // 自动导入 hooks
}),
createHtmlPlugin({
minify: true,
pages: [
{
entry: "src/main.ts",
template: "index.html",
filename: "index.html",
injectOptions: {
data: {
title: "办事大厅 · 在线办理",
injectScript: ``,
},
},
},
],
}),
UnoCSS({
presets: [
presetUno(), // 核心预设(Tailwind风格)
presetRemToPx({ baseFontSize: 4 }), // 1单位=1px(去rem化)
],
shortcuts: {
// 布局
"flex-center": "flex items-center justify-center",
// 常用组件
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
"btn-primary": "bg-blue-600 text-white hover:bg-blue-700",
card: "bg-white rounded-8px shadow-md p-16px",
// 文本截断
"text-ellipsis": "truncate",
"text-multi-ellipsis": "line-clamp-2",
},
theme: {
fontSize: {
xs: "12px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
},
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: "localhost",
port: 8089,
proxy: {
// dev 模式:serviceHost 为空(同源相对路径 /api/...),经此代理转发到本地后端
// (后端 CAS 过滤器对 /api/* 不回 CORS 头,浏览器直连 8086 会被拦,必须同源代理)
"/api": {
target: "http://localhost:8086",
changeOrigin: true,
},
},
},
// css预处理器
css: {
// preprocessorOptions: {
// scss: {
// additionalData: '@import "./src/assets/styles/utils/_varible.scss";',
// },
// },
},
base: "./",
build: {
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
outDir, // 构建输出目录(恒 dist 根,见文件头说明)
assetsDir: "assets", // 静态资源得存放路径文件名 assets
sourcemap: false, //构建后是否生成 source map 文件
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
chunkSizeWarningLimit: 1000, //chunk 大小警告的限制(以 kbs 为单位)默认:500
cssTarget: "chrome61", //防止 vite 将 rgba() 颜色转化为 #RGBA 十六进制符号的形式 (要兼容的场景是安卓微信中的 webview 时,它不支持 CSS 中的 #RGBA 十六进制颜色符号)
rollupOptions: {
input: {
main: resolve("index.html"),
},
output: {
chunkFileNames: "static/js/[name]-[hash].js",
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: (assetInfo: any) => {
if (assetInfo.type === "asset" && /\.(jpe?g|png|gif|svg)$/i.test(assetInfo.name)) {
return "static/img/[name].[hash][ext]";
}
if (assetInfo.type === "asset" && /\.(ttf|woff|woff2|eot)$/i.test(assetInfo.name)) {
return "static/fonts/[name].[hash][ext]";
}
return "static/[ext]/[name]-[hash].[ext]";
},
},
manualEntrypoint: "index.html",
},
},
};
});