初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# 开发环境配置
VITE_LOGIN_URL=http://127.0.0.1:5001
VITE_WEB_URL=http://127.0.0.1:5001
VITE_GATEWAY_URL=http://127.0.0.1:9000
VITE_COOKIE_DOMAIN=

View File

@@ -0,0 +1,5 @@
# 生产环境配置
VITE_LOGIN_URL=https://login.user.zgitm.com
VITE_WEB_URL=https://web.gw.zgitm.com
VITE_GATEWAY_URL=https://gw.server.zgitm.com
VITE_COOKIE_DOMAIN=.zgitm.com

1
mokee-gateway-web/.nvmrc Normal file
View File

@@ -0,0 +1 @@
18

View File

@@ -0,0 +1,945 @@
<!--
系统接入文档页面公开访问通过 docToken 路由访问
路由/docs/:token
页面路由在 router 中配置为无需登录即可访问公开文档页
内容包含 8 个章节
1. 当前系统配置 展示系统基本信息编码名称前后端地址
2. 架构概览 ASCII 架构图 + 5 步流程说明 + 3 条关键规则
3. 前端接入 完整的 Axios 实例和路由守卫复制即用代码
4. 后端接入 Java/Python/Go 三种语言的 UserContext 中间件示例
5. API 接口参考 GET 用户信息GET 菜单路由POST 退出登录
6. 数据库初始化 生成当前系统的菜单和角色初始化 SQL
7. 创建网关管理员账号 生成 gateway-app-admin 账号的 SQL
8. 对接测试清单 6 项验收检查
数据来源
- 系统信息GET /zgapi/v1/admin/system/doc/:token公开接口无需登录
SQL 生成逻辑
- menuSql根据当前系统的 systemCode 生成菜单角色角色-菜单关联的 INSERT 语句
- adminSql生成应用系统管理员账号系统绑定角色分配的 INSERT 语句
密码使用 BCrypt 密文$2a$10$...账号格式为 admin_{systemCode}
角色编码固定为 gateway-app-admin仅能看到自己绑定系统的数据
所有 INSERT 使用 NOT EXISTS 子查询防止重复执行
-->
<template>
<div class="doc-page">
<div class="doc-container">
<!-- ====== 页头 ====== -->
<header class="doc-hero">
<div class="doc-hero-left">
<svg class="doc-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<div>
<h1>{{ sys?.systemName || '系统' }} 接入文档</h1>
<p>Mokee Gateway 统一登录网关 · 系统对接完整指南</p>
</div>
</div>
<div class="doc-hero-right">
<el-tag size="large" type="primary" effect="dark" round>系统编码: {{ sys?.systemCode }}</el-tag>
</div>
</header>
<!-- ====== 系统信息 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">1</span>当前系统配置</h2>
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="系统编码"><code class="inline-code">{{ sys?.systemCode }}</code></el-descriptions-item>
<el-descriptions-item label="系统名称">{{ sys?.systemName }}</el-descriptions-item>
<el-descriptions-item label="前端地址"><code class="inline-code">{{ sys?.frontUrl }}</code></el-descriptions-item>
<el-descriptions-item label="后端地址"><code class="inline-code">{{ sys?.backendUrl }}</code></el-descriptions-item>
<el-descriptions-item label="系统描述" :span="2">{{ sys?.description || '-' }}</el-descriptions-item>
</el-descriptions>
</section>
<!-- ====== 架构概览 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">2</span>架构概览</h2>
<div class="arch-diagram">
<pre class="arch-text">
业务前端 Mokee Gateway 业务后端
(Vue/React) gw.server.zgitm.com (任意语言)
携带 Token 验证 Token 从请求头读取:
+ SystemCode 识别系统编码 X-User-Id
转发请求 X-User-Name
注入用户信息头 X-System-Code
</pre>
</div>
<div class="flow-steps">
<div class="flow-step"><b></b> 用户访问业务前端 Token 跳转统一登录页</div>
<div class="flow-step"><b></b> 登录成功 获得 JWT Token 回跳到业务前端</div>
<div class="flow-step"><b></b> 业务前端所有 API 请求 网关 (携带 Authorization + X-System-Code)</div>
<div class="flow-step"><b></b> 网关验证 Token 注入用户信息请求头 转发到业务后端</div>
<div class="flow-step"><b></b> 业务后端从请求头读取用户信息 执行业务逻辑</div>
</div>
<div class="arch-warnings">
<div class="warn-item">
<b>🔴 关键规则一前端只调网关</b>
<p>业务前端的 <code>baseURL</code> 必须配置为 <b>网关地址</b> <code>{{ gatewayUrl }}</code><b>绝对不能</b>直接调用业务后端网关根据请求头 <code>X-System-Code</code> 自动识别并转发到对应后端</p>
</div>
<div class="warn-item">
<b>🔴 关键规则二后端必须开跨域 (CORS)</b>
<p>业务后端需要允许来自网关域名的跨域请求网关转发请求时 origin 是浏览器地址不是网关地址</p>
<p><b>Java Spring Boot 配置</b></p>
<div class="code-box"><pre><code>@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}</code></pre></div>
</div>
<div class="warn-item">
<b>🔴 关键规则三业务后端不再做登录/鉴权</b>
<p>Token 验证由网关完成业务后端只需从请求头读取用户信息不需要再验证 Token不需要查询用户表</p>
</div>
</div>
</section>
<!-- ====== 前端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">3</span>前端接入 (完整代码)</h2>
<h3 class="section-h3">3.1 安装依赖</h3>
<div class="code-box">
<div class="code-label">Shell</div>
<pre><code>npm install axios</code></pre>
</div>
<h3 class="section-h3">3.2 环境变量 (.env)</h3>
<div class="code-box">
<div class="code-label">.env</div>
<pre><code>VITE_GATEWAY_URL = {{ gatewayUrl }}
VITE_LOGIN_URL = {{ loginUrl }}
VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}</code></pre>
</div>
<h3 class="section-h3">3.3 Axios 实例 (src/utils/request.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import axios from 'axios'
import { ElMessage } from 'element-plus'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const request = axios.create({
baseURL: import.meta.env.VITE_GATEWAY_URL,
timeout: 30000,
})
// 从 Cookie 读取 Token登录后网关写入的跨域 Cookie
function getToken(): string | null {
const match = document.cookie.match(/(?:^|;\\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器401 自动跳转登录
request.interceptors.response.use(
res => res.data.code === 200 ? res.data : Promise.reject(res.data),
err => {
if (err.response?.status === 401) {
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export default request</code></pre>
</div>
<h3 class="section-h3">3.4 路由守卫 (src/router/index.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import { createRouter, createWebHistory } from 'vue-router'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const router = createRouter({
history: createWebHistory(),
routes: [ /* 你的路由 */ ],
})
router.beforeEach((to, _from, next) => {
const token = document.cookie.match(/(?:^|;\\s*)token=([^;]*)/)?.[1]
if (!token && to.path !== '/login') {
// 无 Token → 跳统一登录页
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router</code></pre>
</div>
<h3 class="section-h3">3.5 登录成功回调处理</h3>
<p class="text-p">
用户在统一登录页登录成功后网关将 Token 写入 <b>Cookie</b>domain: <code>.zgitm.com</code>同域子站共享
然后跳回业务前端的地址 <code>VITE_LOGIN_URL</code> 配置的地址
</p>
<p class="text-p">
业务前端<b>无需自行实现登录接口</b>只需从 Cookie 中读取 Token 即可
</p>
<div class="code-box">
<div class="code-label">TypeScript 读取 Cookie 中的 Token</div>
<pre><code>// 从 Cookie 读取 Token登录后网关自动写入的跨域 Cookie
function getToken(): string | null {
const match = document.cookie.match(/(?:^|;\\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// App.vue onMounted 或 main.ts
const token = getToken()
if (token) {
// 用户已登录,正常渲染应用
// 如需获取用户信息,调用网关 API
const res = await request.get('/zgapi/v1/auth/user/info')
console.log('当前用户:', res.data)
} else {
// 无 Token → 跳统一登录页
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}</code></pre>
</div>
<div class="warn-item" style="margin-top:16px">
<b>💡 Token 存储说明</b>
<p>Token 存在 Cookie 而非 localStorage因为 Cookie 支持跨子域共享<code>.zgitm.com</code>
这样统一登录管理后台文件服务等不同子站都能读取到同一个 Token<br>
Cookie 有效期 2 小时 Token 过期时间一致</p>
</div>
</section>
<!-- ====== 后端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">4</span>后端接入 (完整代码)</h2>
<h3 class="section-h3">4.1 网关转发到后端时注入的请求头</h3>
<el-table :data="headerTable" border stripe size="large" style="width:100%">
<el-table-column prop="name" label="请求头名称" width="200" />
<el-table-column prop="type" label="数据类型" width="120" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p class="text-p" style="margin-top:16px"><b>重要</b>这些请求头由网关注入业务后端可以信任这些值不需要再验证 Token 或查询用户信息</p>
<h3 class="section-h3">4.2 Java UserContext 工具类</h3>
<p class="text-p"> ThreadLocal 缓存请求头业务代码零侵入获取用户信息</p>
<div class="code-box">
<div class="code-label">Java</div>
<pre><code>// ====== UserContext.java ======
import java.util.*;
public class UserContext {
private static final ThreadLocal&lt;Map&lt;String, String&gt;&gt; CTX = new ThreadLocal&lt;&gt;();
public static void set(Map&lt;String, String&gt; user) { CTX.set(user); }
public static Map&lt;String, String&gt; get() { return CTX.get(); }
public static void clear() { CTX.remove(); }
// 快捷方法
public static String getUserId() { return get().get("X-User-Id"); }
public static String getUserName() { return get().get("X-User-Name"); }
public static String getUserAccount(){ return get().get("X-User-Account"); }
public static String getSystemCode() { return get().get("X-System-Code"); }
public static String getSystemId() { return get().get("X-System-Id"); }
}
// ====== UserContextFilter.java ======
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;
@Component
public class UserContextFilter implements Filter {
private static final String[] HEADERS = {
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Post", "X-User-Roles",
"X-System-Id", "X-System-Code", "X-System-Name"
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
Map&lt;String, String&gt; user = new HashMap&lt;&gt;();
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null) user.put(h, v);
}
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
UserContext.clear();
}
}
}
// ====== 业务代码中使用 ======
@RestController
public class OrderController {
@GetMapping("/api/orders")
public List&lt;Order&gt; list() {
String userId = UserContext.getUserId(); // "123"
String userName = UserContext.getUserName(); // "张三"
String systemCode = UserContext.getSystemCode(); // "msg"
return orderService.findByUser(userId, systemCode);
}
}</code></pre>
</div>
<h3 class="section-h3">4.3 Python Flask 中间件</h3>
<div class="code-box">
<div class="code-label">Python</div>
<pre><code># ====== middleware.py ======
from flask import request, g
USER_HEADERS = [
'X-User-Id', 'X-User-Name', 'X-User-Account',
'X-User-Email', 'X-User-Post', 'X-User-Roles',
'X-System-Id', 'X-System-Code', 'X-System-Name',
]
def user_context_middleware():
"""将网关注入的用户信息存到 Flask g 对象"""
for h in USER_HEADERS:
val = request.headers.get(h)
if val:
setattr(g, h.replace('-', '_').lower(), val)
# 注册到 app (Flask)
# app.before_request(user_context_middleware)
# ====== 业务代码中使用 ======
from flask import g
@app.route('/api/orders')
def list_orders():
user_id = g.x_user_id # "123"
user_name = g.x_user_name # "张三"
system_code = g.x_system_code # "msg"
return order_service.query(user_id, system_code)</code></pre>
</div>
<h3 class="section-h3">4.4 Go 中间件</h3>
<div class="code-box">
<div class="code-label">Go</div>
<pre><code>// ====== middleware.go ======
package middleware
import (
"context"
"net/http"
)
type contextKey string
func UserContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
for _, h := range []string{
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Roles",
"X-System-Id", "X-System-Code",
} {
if v := r.Header.Get(h); v != "" {
ctx = context.WithValue(ctx, contextKey(h), v)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 快捷函数
func GetUserID(ctx context.Context) string {
return ctx.Value(contextKey("X-User-Id")).(string)
}
func GetSystemCode(ctx context.Context) string {
return ctx.Value(contextKey("X-System-Code")).(string)
}
// ====== main.go: 注册中间件 ======
// http.Handle("/api/", middleware.UserContext(yourHandler))
// ====== 业务代码 ======
func listOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) // "123"
systemCode := middleware.GetSystemCode(r.Context()) // "msg"
// ...
}</code></pre>
</div>
</section>
<!-- ====== API 接口参考 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">5</span>API 接口参考</h2>
<h3 class="section-h3">5.1 获取当前用户信息</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/user/info</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"email": "admin@zgitm.com",
"phone": "13800000000",
"avatar": null,
"post": "管理员"
}
}</code></pre></div>
</div>
</div>
<h3 class="section-h3">5.2 获取系统菜单路由</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>树形菜单结构包含 path / name / component / children / meta( title, icon)
</div>
</div>
<h3 class="section-h3">5.3 退出登录</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/auth/logout</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
</div>
</section>
<!-- ====== 数据库初始化 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">6</span>数据库初始化</h2>
<p class="text-p">在网关平台的数据库执行以下 SQL{{ sys?.systemName }}创建默认菜单和角色</p>
<div class="code-box">
<div class="code-label">SQL</div>
<pre><code>{{ menuSql }}</code></pre>
</div>
</section>
<!-- ====== 创建网关管理员账号 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">7</span>创建网关管理员账号</h2>
<p class="text-p">
业务系统接入后系统管理员需要登录网关管理后台维护本系统的菜单和角色
执行以下 SQL 创建一个<b>应用系统管理员</b>账号角色编码 <code>gateway-app-admin</code>该管理员只能看到自己绑定系统的数据
</p>
<div class="code-box">
<div class="code-label">SQL 创建应用系统管理员</div>
<pre><code>{{ adminSql }}</code></pre>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意</b>
<p>密码需用 BCrypt 加密可使用在线工具或 Java <code>BCrypt.hashpw("your_password", BCrypt.gensalt())</code> 生成密文<br>
如果已创建过用户跳过步骤 1只执行步骤 2 3 绑定系统和角色即可</p>
</div>
</section>
<!-- ====== 测试清单 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">8</span>对接测试清单</h2>
<div class="checklist">
<div v-for="(item, i) in testChecklist" :key="i" class="check-item">
<el-icon :size="20" color="var(--color-primary)"><component :is="getIconComponent(item.done ? 'CircleCheckFilled' : 'CircleClose')" /></el-icon>
<span>{{ item.text }}</span>
</div>
</div>
</section>
<!-- ====== 文件对象存储服务 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">9</span>文件对象存储服务</h2>
<p class="text-p">
文件上传/下载/删除由独立的<b>文件对象存储服务</b>提供该服务直接对接网关数据库上传后文件信息自动出现在文件列表中
</p>
<el-descriptions :column="2" border size="large" style="margin-bottom:20px">
<el-descriptions-item label="服务地址"><code class="inline-code">https://file.wg.zgitm.com</code></el-descriptions-item>
<el-descriptions-item label="认证方式">Bearer Token一次一密每次操作前重新获取</el-descriptions-item>
<el-descriptions-item label="最大文件">100MB</el-descriptions-item>
<el-descriptions-item label="Token 有效期">12 小时</el-descriptions-item>
</el-descriptions>
<h3 class="section-h3">9.1 获取 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/api/token</div>
<div class="api-headers"><b>Content-Type:</b> application/json;charset=utf-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
}</code></pre></div>
<p style="margin:8px 0"><b>响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzM4NCJ9...",
"expiresIn": 43200,
"systemName": "系统名称"
}
}</code></pre></div>
</div>
<h3 class="section-h3">9.2 上传文件</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/api/upload</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token} &nbsp;|&nbsp; <b>Content-Type:</b> multipart/form-data</div>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 获取 Token
TOKEN=$(curl -s -X POST https://file.wg.zgitm.com/api/token \
-H "Content-Type: application/json" \
-d '{"systemCode":"{{ sys?.systemCode || 'YOUR_CODE' }}"}' | jq -r '.data.token')
# 上传
curl -X POST https://file.wg.zgitm.com/api/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/path/to/file.pdf"</code></pre></div>
<p style="margin:8px 0"><b>成功响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"fileId": "a1b2c3d4e5f67890...",
"fileName": "报告.pdf",
"fileSize": 1048576,
"uploadTime": "2026-06-13T10:30:00"
}
}</code></pre></div>
</div>
<h3 class="section-h3">9.3 下载文件</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/api/download/{fileId}</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token}</div>
<p style="margin:8px 0">
成功返回文件二进制流<br>
<b>Token 两种传递方式</b><br>
Header<code>Authorization: Bearer &lt;token&gt;</code><br>
Query 参数<code>/api/download/{fileId}?token=&lt;token&gt;</code>浏览器直链下载时使用
</p>
</div>
<h3 class="section-h3">9.4 删除文件</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/api/delete/{fileId}</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token}</div>
<p style="margin:8px 0">删除会同时清理磁盘文件与数据库记录不可恢复</p>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意事项</b>
<p>1. 每次操作前需重新获取 Token一次一密<br>
2. 文件最大 100MB超时 12 小时<br>
3. 上传成功后文件信息自动写入网关数据库 sys_file <br>
4. 删除不可恢复请谨慎操作</p>
</div>
</section>
<!-- ====== 对外开放 API邮件发送 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">10</span>对外开放 API</h2>
<p class="text-p">
以下接口通过<b>一次性 Token</b> 认证Token 获取后 12 小时内有效<b>每次调用业务接口即消费</b>
</p>
<h3 class="section-h3">10.1 获取 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/token</div>
<div class="api-headers"><b>Content-Type:</b> application/json;charset=UTF-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"systemCode": "{{ sys?.systemCode || 'YOUR_CODE' }}"
}</code></pre></div>
<p style="margin:8px 0"><b>响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzM4NCJ9...",
"expiresIn": 43200,
"systemName": "系统名称",
"usageNote": "此 Token 只能消费一次,消费后立即失效"
}
}</code></pre></div>
</div>
<h3 class="section-h3">10.2 发送邮件消费 Token</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/open/email/send</div>
<div class="api-headers"><b>Authorization:</b> Bearer {token} &nbsp;|&nbsp; <b>Content-Type:</b> application/json;charset=UTF-8</div>
<p style="margin:8px 0"><b>请求体</b></p>
<div class="code-box"><pre><code>{
"to": "user@example.com",
"subject": "邮件主题",
"content": "&lt;h1&gt;邮件内容&lt;/h1&gt;",
"contentType": "html"
}</code></pre></div>
<p style="margin:8px 0">
<b>参数说明</b><br>
<code>to</code> 收件人邮箱必填<br>
<code>subject</code> 邮件主题必填<br>
<code>content</code> 邮件内容必填支持 HTML<br>
<code>contentType</code> <code>"html"</code>默认 <code>"text"</code>
</p>
<p style="margin:8px 0"><b>curl 示例</b></p>
<div class="code-box"><pre><code># 1. 获取 Token
TOKEN=$(curl -s -X POST https://gw.server.zgitm.com/zgapi/v1/open/token \
-H "Content-Type: application/json" \
-d '{"systemCode":"{{ sys?.systemCode || 'YOUR_CODE' }}"}' | jq -r '.data.token')
# 2. 发送邮件
curl -X POST https://gw.server.zgitm.com/zgapi/v1/open/email/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"subject": "测试邮件",
"content": "&lt;h1&gt;你好&lt;/h1&gt;&lt;p&gt;这是一封测试邮件&lt;/p&gt;",
"contentType": "html"
}'</code></pre></div>
<p style="margin:8px 0"><b>成功响应</b></p>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"msg": "发送成功",
"tokenConsumed": true
}
}</code></pre></div>
<p style="margin:8px 0">
<b>注意</b>邮件发送成功后才消费 Token发送失败不消费 Token可重试
</p>
</div>
<div class="warn-item" style="margin-top:16px">
<b> 注意事项</b>
<p>1. 每个 Token 只能使用一次调用任意业务接口即消费<br>
2. 发送邮件前请先获取新 Token<br>
3. 发件人为 <code>admin@zgitm.com</code>阿里企业邮箱<br>
4. 编码统一使用 UTF-8</p>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
const route = useRoute()
const sys = ref<any>(null) // 系统信息对象systemCode, systemName, frontUrl, backendUrl, description
// docToken 从 URL params 读取,用于请求系统文档信息
const token = route.params.token as string
// 网关地址和登录地址从环境变量读取,未配置时使用默认值
const gatewayUrl = computed(() => import.meta.env.VITE_GATEWAY_URL || 'https://gw.server.zgitm.com')
const loginUrl = computed(() => import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com')
// 网关转发时注入的 HTTP 请求头列表
// 业务后端从这些请求头中读取用户信息,无需自行验证 Token
const headerTable = [
{ name: 'X-User-Id', type: 'String', desc: '当前登录用户的唯一ID' },
{ name: 'X-User-Name', type: 'String', desc: '用户真实姓名' },
{ name: 'X-User-Account', type: 'String', desc: '用户登录账号' },
{ name: 'X-User-Email', type: 'String', desc: '用户邮箱' },
{ name: 'X-User-Post', type: 'String', desc: '用户岗位' },
{ name: 'X-User-Roles', type: 'JSON', desc: '用户角色列表 [{roleId, roleName, roleCode}]' },
{ name: 'X-System-Id', type: 'String', desc: '当前业务系统的数据库ID' },
{ name: 'X-System-Code', type: 'String', desc: '当前业务系统的唯一编码' },
{ name: 'X-System-Name', type: 'String', desc: '当前业务系统的显示名称' },
]
/**
* 生成菜单和角色初始化 SQL
*
* 内容:
* 1. 创建"系统管理"和"业务管理"两个顶级目录
* 2. 创建"用户管理"、"角色管理"、"订单管理"三个子菜单
* 3. 创建"管理员"和"普通用户"两个角色
* 4. 将所有菜单分配给管理员角色
*
* SQL 特点:
* - 使用 SELECT ... FROM sys_system WHERE system_code = 'xxx' 子查询获取当前系统的 system_id
* - 子菜单的 parent_id 通过自连接获取对应父菜单 ID
*/
const menuSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
const sn = sys.value?.systemName || '系统'
return `-- =============================================
-- ${sn} 菜单 & 角色初始化 SQL
-- 系统编码: ${sc}
-- 将 system_code 替换为你的编码后直接执行
-- =============================================
-- 1. 创建顶级菜单目录 (parent_id = 0)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 2. 创建子菜单 (parent_id = 对应父菜单ID)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '业务管理' AND m.system_id = s.id;
-- 3. 创建角色
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '管理员', 'admin', '拥有全部权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '普通用户', 'user', '拥有基本权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 4. 将菜单分配给角色
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id
FROM sys_role r, sys_menu m, sys_system s
WHERE r.role_code = 'admin'
AND r.system_id = s.id
AND m.system_id = s.id
AND s.system_code = '${sc}';`
})
/**
* 生成网关管理员账号创建 SQL
*
* 步骤:
* 1. 创建管理员用户(用户名 admin_{systemCode},密码为 admin123 的 BCrypt 密文)
* 使用 NOT EXISTS 防止重复创建
* 2. 绑定用户到 gateway 系统sys_user_system 表)
* 3. 分配 gateway-app-admin 角色(该角色只能看到自己绑定系统的数据)
*
* 注意:
* - BCrypt 密文需自行生成替换,当前示例密文对应密码 "admin123"
* - 如果用户已存在,步骤 1 跳过,只执行步骤 2、3
* - 所有 INSERT 使用 NOT EXISTS 防止重复执行
*/
const adminSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
return `-- =============================================
-- 创建网关管理平台的应用系统管理员账号
-- 角色编码: gateway-app-admin (只能看到自己绑定系统的数据)
-- =============================================
-- 1. 创建管理员用户(密码 'admin123' 的 BCrypt 密文,建议自行生成替换)
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
SELECT REPLACE(UUID(),'-',''), 'admin_${sc}',
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
'${sys.value?.systemName || '系统'}管理员', 'admin@${sc}.com', NULL, 1, 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_${sc}');
-- 2. 绑定管理员到网关系统 (gateway)
INSERT INTO sys_user_system (id, user_id, system_id)
SELECT REPLACE(UUID(),'-',''), u.id, s.id
FROM sys_user u, sys_system s
WHERE u.username = 'admin_${sc}'
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_system us
WHERE us.user_id = u.id AND us.system_id = s.id
);
-- 3. 分配 gateway-app-admin 角色gateway 系统下的应用管理员角色)
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(),'-',''), u.id, r.id
FROM sys_user u, sys_role r, sys_system s
WHERE u.username = 'admin_${sc}'
AND r.role_code = 'gateway-app-admin'
AND r.system_id = s.id
AND s.system_code = 'gateway'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
);`
})
// 测试清单(全部默认未完成,由接入方自行勾选确认)
const testChecklist = computed(() => [
{ text: '访问业务前端首页 → 自动跳转到统一登录页', done: false },
{ text: '使用测试账号登录 → 成功进入业务前端', done: false },
{ text: '调用 GET /zgapi/v1/auth/user/info → 返回用户信息', done: false },
{ text: '业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID', done: false },
{ text: '未放行的 API 请求被网关返回 403', done: false },
{ text: '退出登录后访问业务页面 → 重新跳转登录页', done: false },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/system/doc/' + token)
sys.value = res.data
} catch { /* ignore */ }
})
</script>
<style scoped>
/* ===== 整体 ===== */
.doc-page { min-height:100vh; background:var(--bg-page); padding:20px; }
.doc-container { max-width:960px; margin:0 auto; }
/* ===== 页头 ===== */
.doc-hero {
display:flex; align-items:center; justify-content:space-between;
padding:28px 32px; background:var(--bg-surface);
border-radius:var(--radius-xl); border:1px solid var(--border-color);
margin-bottom:24px; box-shadow:var(--shadow-sm);
}
.doc-hero-left { display:flex; align-items:center; gap:14px; }
.doc-logo { width:36px; height:36px; color:var(--color-primary); flex-shrink:0; }
.doc-hero-left h1 { font-size:22px; font-weight:700; margin:0 0 4px; color:var(--text-primary); }
.doc-hero-left p { font-size:14px; color:var(--text-muted); margin:0; }
/* ===== 章节 ===== */
.doc-section {
background:var(--bg-surface); border-radius:var(--radius-xl);
border:1px solid var(--border-color); padding:28px 32px;
margin-bottom:20px; box-shadow:var(--shadow-sm);
}
.section-h2 {
display:flex; align-items:center; gap:12px;
font-size:20px; font-weight:700; color:var(--text-primary);
margin:0 0 20px; padding-bottom:16px;
border-bottom:1px solid var(--border-light);
}
.s-num {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:var(--radius-md);
background:var(--color-primary); color:#fff;
font-size:15px; font-weight:700; flex-shrink:0;
}
.section-h3 {
font-size:16px; font-weight:600; color:var(--text-primary);
margin:24px 0 10px;
}
.section-h3:first-of-type { margin-top:0; }
/* ===== 文字 ===== */
.text-p { font-size:14px; color:var(--text-secondary); line-height:1.7; margin:0 0 12px; }
.inline-code {
background:var(--color-primary-bg); color:var(--color-primary);
padding:2px 8px; border-radius:4px; font-size:13px;
font-family:'SF Mono','Consolas',monospace;
}
/* ===== 架构图 ===== */
.arch-text {
background:#1e293b; color:#e2e8f0; padding:16px 20px;
border-radius:var(--radius-md); font-size:13px;
line-height:1.7; overflow-x:auto; white-space:pre;
font-family:'SF Mono','Consolas',monospace;
}
.flow-steps { margin-top:16px; display:flex; flex-direction:column; gap:10px; }
.flow-step { font-size:14px; color:var(--text-secondary); display:flex; align-items:flex-start; gap:8px; line-height:1.6; }
.flow-step b { color:var(--color-primary); flex-shrink:0; }
/* ===== 代码块 ===== */
.code-box {
position:relative; background:#1e293b; border-radius:var(--radius-md);
margin-bottom:16px; overflow:hidden;
}
.code-label {
position:absolute; top:8px; right:12px; z-index:1;
font-size:11px; color:rgba(255,255,255,0.45); font-weight:600;
letter-spacing:0.5px; text-transform:uppercase;
}
.code-box pre {
margin:0; padding:20px; overflow-x:auto;
}
.code-box code {
font-family:'SF Mono','Fira Code','Consolas',monospace;
font-size:13px; line-height:1.75; color:#e2e8f0; white-space:pre;
}
/* ===== API 卡片 ===== */
.api-card {
border:1px solid var(--border-color); border-radius:var(--radius-lg);
padding:20px; margin-bottom:16px;
}
.api-method {
display:inline-block; padding:2px 10px; border-radius:4px;
font-size:12px; font-weight:700; color:#fff; margin-bottom:8px;
}
.api-method.get { background:#10b981; }
.api-method.post { background:#6366f1; }
.api-method.put { background:#f59e0b; }
.api-path {
font-size:15px; font-weight:600; color:var(--text-primary);
font-family:'SF Mono','Consolas',monospace; margin-bottom:8px;
}
.api-headers { font-size:14px; color:var(--text-secondary); margin-bottom:8px; }
.api-response { font-size:14px; color:var(--text-secondary); }
/* ===== 测试清单 ===== */
.checklist { display:flex; flex-direction:column; gap:14px; }
.check-item { display:flex; align-items:center; gap:10px; font-size:14px; color:var(--text-secondary); }
/* 架构警告 */
.arch-warnings { margin-top:16px; display:flex; flex-direction:column; gap:12px; }
.warn-item {
background:#fef2f2; border:1px solid #fecaca; border-radius:var(--radius-md);
padding:14px 18px;
}
.warn-item b { color:#dc2626; font-size:14px; display:block; margin-bottom:4px; }
.warn-item p { color:var(--text-secondary); font-size:14px; margin:0 0 8px; line-height:1.7; }
@media (max-width:768px) {
.doc-hero { flex-direction:column; gap:12px; align-items:flex-start; }
.doc-section { padding:20px; }
}
</style>

View File

@@ -0,0 +1,29 @@
{
"name": "mokee-gateway-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5001",
"build": "vue-tsc && vite build",
"build:prod": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"echarts": "^6.1.0",
"element-plus": "^2.8.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.4.0",
"unplugin-auto-import": "^0.18.0",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0"
}
}

10
mokee-gateway-web/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

View File

@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as Icons from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/global.css'
import { permission } from './directives/permission'
const app = createApp(App)
for (const [key, comp] of Object.entries(Icons)) {
app.component(key, comp as any)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.directive('permission', permission)
app.mount('#app')

View File

@@ -0,0 +1,557 @@
<template>
<div class="admin-shell">
<!-- ===== 侧边栏树形菜单 ===== -->
<aside class="sidebar" :class="{ open: mobileSidebarOpen }">
<!-- Logo -->
<div class="sidebar-logo" @click="router.push('/admin/dashboard')">
<svg class="logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="logo-text">Mokee</span>
</div>
<!-- 菜单树 -->
<nav class="sidebar-nav">
<!-- 首页 -->
<div
class="menu-item single"
:class="{ active: activeMenu === '/admin/dashboard' }"
@click="openPage('/admin/dashboard', '首页')"
>
<el-icon :size="18"><HomeFilled /></el-icon>
<span>首页</span>
</div>
<!-- 系统管理 -->
<div class="menu-group" :class="{ open: menuOpen.system }">
<div class="menu-item parent" :class="{ active: activeGroup === 'system' }" @click="menuOpen.system = !menuOpen.system">
<el-icon :size="18"><Monitor /></el-icon>
<span>系统管理</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.system }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.system">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/system' }" @click="openPage('/admin/system', '系统列表')">
<span>系统列表</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/api' }" @click="openPage('/admin/api', '接口管理')">
<span>接口管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/dict' }" @click="openPage('/admin/dict', '数据字典')">
<span>数据字典</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/file' }" @click="openPage('/admin/file', '文件管理')">
<span>文件管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/email' }" @click="openPage('/admin/email', '邮件管理')">
<span>邮件管理</span>
</div>
</div>
</div>
<!-- 用户权限 -->
<div class="menu-group" :class="{ open: menuOpen.user }">
<div class="menu-item parent" :class="{ active: activeGroup === 'user' }" @click="menuOpen.user = !menuOpen.user">
<el-icon :size="18"><UserFilled /></el-icon>
<span>用户权限</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.user }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.user">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/user' }" @click="openPage('/admin/user', '用户管理')">
<span>用户管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/role' }" @click="openPage('/admin/role', '角色管理')">
<span>角色管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/menu' }" @click="openPage('/admin/menu', '菜单管理')">
<span>菜单管理</span>
</div>
</div>
</div>
<!-- 日志审计 -->
<div class="menu-group" :class="{ open: menuOpen.log }">
<div class="menu-item parent" :class="{ active: activeGroup === 'log' }" @click="menuOpen.log = !menuOpen.log">
<el-icon :size="18"><Document /></el-icon>
<span>日志审计</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.log }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.log">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/login' }" @click="openPage('/admin/log/login', '登录日志')">
<span>登录日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/operation' }" @click="openPage('/admin/log/operation', '操作日志')">
<span>操作日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/token' }" @click="openPage('/admin/log/token', 'Token日志')">
<span>Token日志</span>
</div>
</div>
</div>
</nav>
<!-- 底部 -->
<div class="sidebar-footer">
<div class="menu-item single" @click="router.push('/select')">
<el-icon :size="18"><Switch /></el-icon>
<span>切换系统</span>
</div>
</div>
</aside>
<!-- 移动端遮罩 -->
<div :class="['sidebar-overlay', { show: mobileSidebarOpen }]" @click="mobileSidebarOpen = false" />
<!-- ===== 右侧主体 ===== -->
<div class="main-area">
<!-- 顶栏 -->
<header class="topbar">
<div class="topbar-left">
<span class="hamburger" @click="mobileSidebarOpen = !mobileSidebarOpen">
<el-icon :size="20"><component :is="mobileSidebarOpen ? Fold : Expand" /></el-icon>
</span>
<el-breadcrumb separator="/" class="breadcrumb-desk">
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbParent">{{ breadcrumbParent }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbCurrent">{{ breadcrumbCurrent }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="topbar-right">
<el-dropdown trigger="hover" placement="bottom-end">
<div class="user-chip">
<el-avatar :size="28" :src="authStore.userInfo?.avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<span class="user-name">{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}</span>
<el-icon :size="12" class="user-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/admin/personal')">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item @click="router.push('/select')">
<el-icon><Switch /></el-icon>切换系统
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 标签栏 -->
<div class="tab-bar" v-if="tabsStore.tabs.length > 0">
<div class="tab-list" ref="tabListRef">
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
:class="['tab-chip', { active: tabsStore.activeTab === tab.path }]"
@click="switchTab(tab)"
@contextmenu.prevent="showMenu($event, tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<el-icon
v-if="tabsStore.tabs.length > 1"
class="tab-close"
:size="12"
@click.stop="closeTab(tab.path)"
><Close /></el-icon>
</div>
</div>
<el-dropdown trigger="click" @command="handleTabCmd" class="tab-more">
<span class="tab-more-btn">
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">
<el-icon><Close /></el-icon>关闭当前
</el-dropdown-item>
<el-dropdown-item command="closeOthers">
<el-icon><Remove /></el-icon>关闭其他
</el-dropdown-item>
<el-dropdown-item command="closeAll">
<el-icon><CircleClose /></el-icon>关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="ctxMenu.visible"
class="ctx-overlay"
@click="ctxMenu.visible = false"
>
<div
class="ctx-menu"
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
@click.stop
>
<div class="ctx-item" @click="closeTab(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Close /></el-icon>关闭
</div>
<div class="ctx-item" @click="closeOthers(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Remove /></el-icon>关闭其他
</div>
<div class="ctx-item" @click="closeAll(); ctxMenu.visible = false">
<el-icon :size="14"><CircleClose /></el-icon>关闭全部
</div>
</div>
</div>
</Teleport>
<!-- 内容区 -->
<main class="content">
<router-view :key="tabsStore.activeTab" />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
HomeFilled, Monitor, UserFilled, Document,
ArrowRight, Switch, ArrowDown, SwitchButton,
User, Close, Remove, CircleClose, Expand, Fold,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useTabsStore } from '@/stores/tabs'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const tabsStore = useTabsStore()
const tabListRef = ref<HTMLElement>()
const mobileSidebarOpen = ref(false)
// 菜单展开状态
const menuOpen = reactive({ system: true, user: true, log: true })
// 当前高亮
const activeMenu = computed(() => {
const p = route.path
const known = ['/admin/dashboard', '/admin/system', '/admin/api', '/admin/dict', '/admin/file', '/admin/email',
'/admin/user', '/admin/role', '/admin/menu', '/admin/log/login', '/admin/log/operation', '/admin/log/token']
return known.find(k => p.startsWith(k)) || p
})
const activeGroup = computed(() => {
const p = route.path
if (p.startsWith('/admin/system') || p.startsWith('/admin/api') || p.startsWith('/admin/dict') || p.startsWith('/admin/file') || p.startsWith('/admin/email')) return 'system'
if (p.startsWith('/admin/user') || p.startsWith('/admin/role') || p.startsWith('/admin/menu')) return 'user'
if (p.startsWith('/admin/log')) return 'log'
return ''
})
// 面包屑
const breadcrumbMap: Record<string, { parent?: string; current: string }> = {
'/admin/dashboard': { current: '首页' },
'/admin/system': { parent: '系统管理', current: '系统列表' },
'/admin/api': { parent: '系统管理', current: '接口管理' },
'/admin/dict': { parent: '系统管理', current: '数据字典' },
'/admin/file': { parent: '系统管理', current: '文件管理' },
'/admin/email': { parent: '系统管理', current: '邮件管理' },
'/admin/user': { parent: '用户权限', current: '用户管理' },
'/admin/role': { parent: '用户权限', current: '角色管理' },
'/admin/menu': { parent: '用户权限', current: '菜单管理' },
'/admin/log/login': { parent: '日志审计', current: '登录日志' },
'/admin/log/operation': { parent: '日志审计', current: '操作日志' },
'/admin/log/token': { parent: '日志审计', current: 'Token日志' },
'/admin/user/form': { parent: '用户权限', current: '用户表单' },
'/admin/role/form': { parent: '用户权限', current: '角色表单' },
'/admin/menu/form': { parent: '用户权限', current: '菜单表单' },
'/admin/system/form': { parent: '系统管理', current: '系统表单' },
'/admin/api/form': { parent: '系统管理', current: '接口表单' },
'/admin/dict/form': { parent: '系统管理', current: '字典表单' },
'/admin/personal': { current: '个人中心' },
}
const breadcrumbParent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].parent : undefined
})
const breadcrumbCurrent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].current : undefined
})
// ===== 标签页 =====
function openPage(path: string, title: string) {
tabsStore.addTab({ path, meta: { title }, query: {} } as any)
router.push(path)
mobileSidebarOpen.value = false
}
function switchTab(tab: any) {
router.push(tab.path)
}
function closeTab(path: string) {
tabsStore.removeTab(path)
if (tabsStore.activeTab) {
router.push(tabsStore.activeTab)
} else {
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
}
function closeOthers(path?: string) {
if (path) tabsStore.closeOthers(path)
}
function closeAll() {
tabsStore.closeAll()
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
function handleTabCmd(cmd: string) {
const cur = tabsStore.activeTab
if (cmd === 'closeCurrent' && cur) closeTab(cur)
else if (cmd === 'closeOthers' && cur) closeOthers(cur)
else if (cmd === 'closeAll') closeAll()
}
// 右键菜单
const ctxMenu = reactive({ visible: false, x: 0, y: 0, tab: null as any })
function showMenu(e: MouseEvent, tab: any) {
ctxMenu.visible = true
ctxMenu.x = e.clientX
ctxMenu.y = e.clientY
ctxMenu.tab = tab
}
// 路由变化自动加标签
watch(() => route.path, (p) => {
if (p.startsWith('/admin') && p !== '/admin') {
tabsStore.addTab(route)
}
})
onMounted(async () => {
// 跨域名跳转后 Pinia 状态丢失,重新从服务端加载用户信息
await authStore.fetchUserInfo()
if (route.path !== '/admin' && route.path !== '/admin/dashboard') {
tabsStore.addTab(route)
}
// 首页默认标签
if (!tabsStore.tabs.find((t: any) => t.path === '/admin/dashboard')) {
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
if (route.path === '/admin') {
router.push('/admin/dashboard')
}
})
async function handleLogout() {
try { await authStore.logout() } catch {}
tabsStore.closeAll()
// 直接完整跳转登录页,避免 router.push 触发组件 API 请求
window.location.href = import.meta.env.VITE_LOGIN_URL || '/login'
}
</script>
<style scoped>
/* ===== 整体布局 ===== */
.admin-shell { display:flex; height:100vh; overflow:hidden; background:var(--bg-page); }
/* ===== 侧边栏 ===== */
.sidebar {
width: 240px; flex-shrink:0;
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display:flex; flex-direction:column; overflow:hidden; z-index:50;
}
.sidebar-logo {
display:flex; align-items:center; gap:10px;
height: 58px; padding:0 20px;
border-bottom: 1px solid var(--border-light);
cursor:pointer; flex-shrink:0;
}
.logo-svg { width:22px; height:22px; color:var(--color-primary); flex-shrink:0; }
.logo-text { font-size:18px; font-weight:700; color:var(--text-primary); letter-spacing:-0.3px; }
.sidebar-nav { flex:1; overflow-y:auto; padding:16px 12px; }
.sidebar-footer { padding:8px 12px 16px; border-top:1px solid var(--border-light); flex-shrink:0; }
/* ===== 菜单项 ===== */
.menu-item {
display:flex; align-items:center; gap:10px;
height:42px; padding:0 14px;
border-radius: var(--radius-md);
cursor:pointer; font-size:14px; font-weight:500;
color: var(--text-secondary);
transition: all var(--transition-fast);
white-space:nowrap;
}
.menu-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.menu-item.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:600; }
.menu-item.single { margin-bottom:4px; }
.menu-item.parent {
font-weight:600; font-size:14px; color:var(--text-primary);
margin-top:2px;
}
.menu-item.child {
padding-left:48px; height:38px;
font-size:14px; font-weight:400;
color: var(--text-muted);
margin:1px 0;
}
.menu-item.child:hover { color:var(--text-primary); }
.menu-item.child.active { color:var(--color-primary); font-weight:500; }
/* 展开箭头 */
.arrow { margin-left:auto; transition:transform var(--transition-fast); color:var(--text-muted); flex-shrink:0; }
.arrow.rotated { transform:rotate(90deg); }
.sub-items { overflow:hidden; padding-bottom:4px; }
/* ===== 分隔线(菜单组之间) ===== */
.menu-group { padding:4px 0; }
.menu-group + .menu-group { border-top:1px solid var(--border-light); }
/* ===== 主体区域 ===== */
.main-area { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
/* ===== 顶栏 ===== */
.topbar {
display:flex; align-items:center; justify-content:space-between;
height:58px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 24px; flex-shrink:0;
}
.topbar-left { display:flex; align-items:center; }
.topbar-right { display:flex; align-items:center; }
.user-chip {
display:flex; align-items:center; gap:8px;
padding:5px 12px 5px 5px; border-radius:24px;
cursor:pointer; transition:all var(--transition-fast);
border:1px solid transparent;
}
.user-chip:hover { background:var(--bg-hover); border-color:var(--border-color); }
.user-name {
font-size:14px; font-weight:500; color:var(--text-secondary);
max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.user-chevron { color:var(--text-muted); flex-shrink:0; }
/* ===== 标签栏 ===== */
.tab-bar {
display:flex; align-items:center;
height:42px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 12px; flex-shrink:0; gap:4px;
}
.tab-list { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; height:100%; align-items:center; gap:2px; }
.tab-list::-webkit-scrollbar { height:2px; }
.tab-chip {
display:flex; align-items:center; gap:6px;
height:34px; padding:0 12px;
border-radius:var(--radius-sm);
cursor:pointer; font-size:13px;
color:var(--text-muted);
transition:all var(--transition-fast);
white-space:nowrap; flex-shrink:0;
}
.tab-chip:hover { background:var(--bg-hover); color:var(--text-secondary); }
.tab-chip.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:500; }
.tab-title { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
.tab-close { border-radius:3px; padding:1px; opacity:0; transition:all var(--transition-fast); }
.tab-chip:hover .tab-close { opacity:1; }
.tab-close:hover { background:var(--border-color); color:var(--text-primary); }
.tab-more { flex-shrink:0; }
.tab-more-btn {
display:flex; align-items:center; justify-content:center;
width:30px; height:30px; border-radius:var(--radius-sm);
cursor:pointer; color:var(--text-muted);
transition:all var(--transition-fast);
}
.tab-more-btn:hover { background:var(--bg-hover); color:var(--text-secondary); }
/* ===== 右键菜单 ===== */
.ctx-overlay { position:fixed; inset:0; z-index:9998; }
.ctx-menu {
position:fixed; z-index:9999;
background:var(--bg-surface); border:1px solid var(--border-color);
border-radius:var(--radius-md); box-shadow:var(--shadow-lg);
min-width:150px; padding:4px;
}
.ctx-item {
display:flex; align-items:center; gap:8px;
padding:9px 14px; font-size:14px; cursor:pointer;
color:var(--text-secondary); border-radius:var(--radius-sm);
transition:all var(--transition-fast);
}
.ctx-item:hover { background:var(--bg-hover); color:var(--text-primary); }
/* ===== 内容区 ===== */
.content { flex:1; overflow-y:auto; padding:24px; }
/* ===== 汉堡按钮 ===== */
.hamburger {
display: none; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: var(--radius-md);
cursor: pointer; color: var(--text-secondary);
margin-right: 8px; flex-shrink: 0;
}
.hamburger:hover { background: var(--bg-hover); color: var(--color-primary); }
/* ===== 侧边栏遮罩 ===== */
.sidebar-overlay {
display: none;
position: fixed; inset: 0; z-index: 99;
background: rgba(0,0,0,0.4);
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.hamburger { display: flex; }
.breadcrumb-desk { display: none; }
/* 侧边栏滑出 */
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
width: 260px !important;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.show { display: block; }
/* 主体全宽 */
.main-area { width: 100%; }
/* 顶栏 */
.topbar { padding: 0 12px; height: 50px; }
.user-chip { padding: 4px 8px 4px 4px; }
.user-name { display: none; }
/* 标签栏 */
.tab-bar { height: 36px; padding: 0 6px; }
.tab-chip { height: 28px; padding: 0 8px; font-size: 12px; }
.tab-title { max-width: 80px; }
/* 内容区 */
.content { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,353 @@
<template>
<div class="dashboard">
<!-- 页面标题 -->
<div class="page-title">
<h2>概览</h2>
<p>网关平台运行状态一览</p>
</div>
<!-- 统计卡片 -->
<div class="stat-grid">
<div class="stat-card" v-for="card in statCards" :key="card.label">
<div class="stat-icon-box" :style="{ background: card.bg, color: card.color }">
<el-icon :size="22"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">
<el-skeleton v-if="loading" animated style="width:40px;height:28px" />
<span v-else>{{ card.value }}</span>
</div>
</div>
</div>
</div>
<!-- 图表 + 服务器信息 -->
<div class="info-grid">
<!-- 登录趋势 -->
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<el-icon :size="16" color="var(--color-primary)"><DataLine /></el-icon>
<span>近30天登录趋势</span>
</div>
</div>
<div class="panel-body">
<el-skeleton v-if="chartLoading" animated :rows="8" />
<template v-else>
<div ref="chartRef" class="chart-box"></div>
<el-empty v-if="!hasData" description="暂无登录数据" :image-size="80" />
</template>
</div>
</div>
<!-- 服务器状态 -->
<div class="server-panel">
<div class="panel-header">
<div class="panel-title">
<el-icon :size="16" color="var(--color-success)"><Monitor /></el-icon>
<span>服务器状态</span>
</div>
<el-tag size="small" type="success" effect="plain" round>运行中</el-tag>
</div>
<div class="panel-body">
<el-skeleton v-if="loading" animated :rows="6" />
<div v-else class="server-metrics">
<div class="metric-item">
<span class="metric-label">CPU 核心</span>
<span class="metric-value">{{ stats.serverInfo?.availableProcessors || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">已用内存</span>
<span class="metric-value">{{ stats.serverInfo?.totalMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">可用内存</span>
<span class="metric-value highlight">{{ stats.serverInfo?.freeMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">最大内存</span>
<span class="metric-value">{{ stats.serverInfo?.maxMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">Java 版本</span>
<span class="metric-value">{{ stats.serverInfo?.javaVersion || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">操作系统</span>
<span class="metric-value">{{ stats.serverInfo?.osName || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { Monitor, User, DataLine, Connection } from '@element-plus/icons-vue'
import request from '@/utils/request'
const stats = reactive<any>({ serverInfo: {} })
const chartRef = ref()
const hasData = ref(false)
const loading = ref(true)
const chartLoading = ref(true)
let chartInstance: any = null
const statCards = ref([
{ label: '系统总数', value: 0, icon: Monitor, bg: '#eef2ff', color: '#6366f1' },
{ label: '用户总数', value: 0, icon: User, bg: '#ecfdf5', color: '#10b981' },
{ label: '今日登录', value: 0, icon: DataLine, bg: '#fffbeb', color: '#f59e0b' },
{ label: '当前在线', value: 0, icon: Connection, bg: '#fef2f2', color: '#ef4444' },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/dashboard/stats')
Object.assign(stats, res.data)
statCards.value[0].value = stats.systemCount || 0
statCards.value[1].value = stats.userCount || 0
statCards.value[2].value = stats.todayLoginCount || 0
statCards.value[3].value = stats.onlineCount || 0
await nextTick()
if (stats.loginTrend?.length) {
hasData.value = true
renderChart()
}
} catch {} finally {
loading.value = false
chartLoading.value = false
}
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (chartInstance) chartInstance.dispose()
})
function handleResize() {
chartInstance?.resize()
}
async function renderChart() {
if (!chartRef.value) return
const echartsModule: any = await import('echarts')
const echarts = echartsModule.default || echartsModule
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: '#e2e8f0',
textStyle: { color: '#1e293b', fontSize: 13 },
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
padding: [12, 16],
},
grid: { left: 40, right: 20, top: 16, bottom: 48 },
xAxis: {
type: 'category',
data: stats.loginTrend.map((d: any) => d.date),
axisLabel: { rotate: 45, fontSize: 11, color: '#94a3b8' },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11, color: '#94a3b8' },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
},
series: [{
data: stats.loginTrend.map((d: any) => d.count),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
showSymbol: false,
emphasis: { showSymbol: true, symbolSize: 8 },
lineStyle: { width: 2.5, color: '#6366f1' },
itemStyle: { color: '#6366f1' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
{ offset: 1, color: 'rgba(99,102,241,0.0)' },
]),
},
}],
})
}
</script>
<style scoped>
.dashboard {
/* 占满宽度 */
}
.page-title {
margin-bottom: 24px;
}
.page-title h2 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
margin-bottom: 4px;
}
.page-title p {
font-size: 14px;
color: var(--text-muted);
}
/* ===== 统计卡片 ===== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
transition: all var(--transition-base);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.stat-icon-box {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-body {
flex: 1;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
font-weight: 500;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.5px;
}
/* ===== 信息网格 ===== */
.info-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.chart-panel,
.server-panel {
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.panel-body {
padding: 20px;
}
.chart-box {
height: 300px;
}
/* ===== 服务器指标 ===== */
.server-metrics {
display: flex;
flex-direction: column;
gap: 0;
}
.metric-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-light);
}
.metric-item:last-child {
border-bottom: none;
}
.metric-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.metric-value {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-value.highlight {
color: var(--color-success);
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.info-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.chart-box { height: 220px; }
.panel-body { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="personal-page">
<div class="profile-grid">
<!-- 头像 + 基本信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><User /></el-icon>
<span>基本信息</span>
</div>
<div class="card-body">
<!-- 头像上传 -->
<div class="avatar-section">
<div class="avatar-wrapper" @click="triggerUpload">
<el-avatar :size="80" :src="form.avatar" class="profile-avatar">
{{ (form.realName || form.username || 'U').charAt(0) }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="avatar-info">
<div class="avatar-name">{{ form.realName || form.username }}</div>
<div class="avatar-role">{{ form.post || '普通用户' }}</div>
<el-button size="small" text type="primary" @click="triggerUpload">
<el-icon><Upload /></el-icon>更换头像
</el-button>
</div>
</div>
<el-form :model="form" label-width="80px" label-position="left" class="info-form">
<el-form-item label="用户名">
<el-input :model-value="form.username" disabled />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveProfile">
<el-icon><Check /></el-icon>保存信息
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码 + 账户信息 -->
<div class="right-cards">
<!-- 修改密码 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><Lock /></el-icon>
<span>修改密码</span>
</div>
<div class="card-body">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px" label-position="left">
<el-form-item label="原密码" prop="oldPwd">
<el-input v-model="pwdForm.oldPwd" type="password" placeholder="请输入原密码" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input v-model="pwdForm.newPwd" type="password" placeholder="至少6位" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPwd">
<el-input v-model="pwdForm.confirmPwd" type="password" placeholder="再次输入" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="changingPwd" @click="changePassword">
<el-icon><Key /></el-icon>修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 账户信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><InfoFilled /></el-icon>
<span>账户信息</span>
</div>
<div class="card-body">
<div class="meta-list">
<div class="meta-row">
<span class="meta-label">用户ID</span>
<code class="meta-value mono">{{ form.userId?.substring(0, 12) || '-' }}...</code>
</div>
<div class="meta-row">
<span class="meta-label">用户名</span>
<span class="meta-value">{{ form.username || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">注册时间</span>
<span class="meta-value">{{ fmtDate(form.createdAt) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">最后登录</span>
<span class="meta-value">{{ fmtDate(form.lastLoginTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Key, Check, InfoFilled, Camera, Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const fileInputRef = ref<HTMLInputElement>()
const form = reactive({
userId: '' as string | null,
username: '',
realName: '',
email: '',
phone: '',
avatar: '',
post: '',
createdAt: '',
lastLoginTime: '',
})
const saving = ref(false)
function fmtDate(s: string | null | undefined): string {
if (!s) return '-'
try {
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return s }
}
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/user/info')
const d = res.data
Object.assign(form, {
userId: d.userId ?? '',
username: d.username ?? '',
realName: d.realName ?? '',
email: d.email ?? '',
phone: d.phone ?? '',
avatar: d.avatar ?? '',
post: d.post ?? '',
createdAt: d.createdAt ?? '',
lastLoginTime: d.lastLoginTime ?? '',
})
setTimeout(() => pwdFormRef.value?.clearValidate(), 0)
} catch { /* ignore */ }
})
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleAvatarUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const fd = new FormData()
fd.append('file', file)
fd.append('fileType', 'avatar')
fd.append('userId', form.userId || '')
try {
const res = await request.post('/zgapi/v1/admin/file/upload', fd)
// 文件存入数据库后用文件ID构建访问路径
const fileId = res.data?.id
if (!fileId) { ElMessage.error('上传返回异常'); return }
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
const avatarUrl = `${gatewayUrl}/zgapi/v1/admin/file/${fileId}/view`
form.avatar = avatarUrl
// 更新用户头像字段
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
avatar: avatarUrl,
})
// 同步刷新 store 中的用户信息(也会更新 Redis TokenInfo
await authStore.fetchUserInfo()
ElMessage.success('头像已更新')
} catch { ElMessage.error('上传失败') }
}
async function saveProfile() {
saving.value = true
try {
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
})
await authStore.fetchUserInfo()
ElMessage.success('个人信息已更新')
} catch { /* ignore */ } finally {
saving.value = false
}
}
// 密码
const pwdFormRef = ref<FormInstance>()
const changingPwd = ref(false)
const pwdForm = reactive({ oldPwd: '', newPwd: '', confirmPwd: '' })
const pwdRules: FormRules = {
oldPwd: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPwd: [
{ required: true, message: '请再次输入', trigger: 'blur' },
{ validator: (_r: any, v: string, cb: any) => v !== pwdForm.newPwd ? cb(new Error('两次密码不一致')) : cb(), trigger: 'blur' },
],
}
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
changingPwd.value = true
try {
await request.put('/zgapi/v1/auth/password/update', null, {
params: { oldPwd: pwdForm.oldPwd, newPwd: pwdForm.newPwd },
})
ElMessage.success('密码修改成功,即将重新登录')
pwdForm.oldPwd = ''; pwdForm.newPwd = ''; pwdForm.confirmPwd = ''
setTimeout(() => {
authStore.logout()
window.location.href = '/login'
}, 1500)
} catch { /* ignore */ } finally {
changingPwd.value = false
}
}
</script>
<style scoped>
.personal-page { max-width: 960px; }
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.right-cards { display: flex; flex-direction: column; gap: 20px; }
.profile-card {
background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); box-shadow: var(--shadow-xs); overflow: hidden;
}
.card-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 14px; color: var(--text-primary);
}
.card-body { padding: 24px 20px; }
/* 头像 */
.avatar-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border-light); }
.avatar-wrapper { position: relative; cursor: pointer; flex-shrink: 0; }
.profile-avatar { box-shadow: 0 4px 12px rgba(99,102,241,0.2); }
.avatar-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity var(--transition-fast); color: #fff;
}
.avatar-wrapper:hover .avatar-overlay { opacity: 1; }
.avatar-name { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.avatar-role { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
/* 表单 */
.info-form :deep(.el-form-item) { margin-bottom: 18px; }
.info-form :deep(.el-form-item:last-child) { margin-bottom: 0; padding-top: 4px; }
/* 元信息 */
.meta-list { display: flex; flex-direction: column; }
.meta-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--border-light); }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.meta-value { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.meta-value.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; gap: 12px; }
.personal-page { padding: 0; }
.avatar-section { flex-direction: column; text-align: center; }
.avatar-info { align-items: center; }
.card-body { padding: 16px; }
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑字典' : '新增字典'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="字典类型" prop="dictType">
<el-select
v-model="form.dictType"
placeholder="请选择或输入"
filterable
allow-create
:disabled="isEdit"
style="width: 100%"
>
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入标签" />
</el-form-item>
<el-form-item label="值" prop="dictValue">
<el-input v-model="form.dictValue" placeholder="请输入值" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface DictTypeItem {
dictType: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 字典类型列表 */
const dictTypeList = ref<DictTypeItem[]>([])
/** 表单数据 */
const form = reactive({
dictType: '',
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
dictType: [{ required: true, message: '请选择或输入字典类型', trigger: 'blur' }],
dictLabel: [{ required: true, message: '请输入标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入值', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载字典类型列表 */
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/** 加载已有数据 */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式,可从 query 预填字典类型
const queryDictType = route.query.dictType
if (queryDictType) {
form.dictType = String(queryDictType)
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/dict/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
dictType: data.dictType ?? '',
dictLabel: data.dictLabel ?? '',
dictValue: data.dictValue ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载字典信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/dict/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/dict', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchDictTypeList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="字典类型">
<el-select v-model="query.dictType" placeholder="全部" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="query.dictLabel" placeholder="请输入标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增字典
</el-button>
<el-button @click="handleAddType">
<el-icon><FolderAdd /></el-icon>
新增字典类型
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="dictType" label="字典类型" width="160" />
<el-table-column prop="dictLabel" label="标签" width="150" />
<el-table-column prop="dictValue" label="值" width="150" />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无数据,请选择字典类型或新增" :image-size="100" />
</template>
</el-table>
</el-card>
<!-- 新增字典类型弹窗 -->
<el-dialog
v-model="typeDialogVisible"
title="新增字典类型"
width="450px"
:close-on-click-modal="false"
>
<el-form ref="typeFormRef" :model="typeForm" :rules="typeRules" label-width="100px">
<el-form-item label="字典类型" prop="dictType">
<el-input v-model="typeForm.dictType" placeholder="请输入字典类型编码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="typeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="typeSubmitLoading" @click="submitAddType">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, FolderAdd } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
/** 字典项类型 */
interface DictItem {
id?: string
dictType: string
dictLabel: string
dictValue: string
sortOrder: number
status: number
}
interface DictTypeItem {
dictType: string
}
// --- 查询 ---
const query = reactive({
dictType: '',
dictLabel: '',
})
const tableData = ref<DictItem[]>([])
const dictTypeList = ref<DictTypeItem[]>([])
const loading = ref(false)
/** 获取字典类型列表 */
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/** 根据字典类型获取数据 */
async function fetchData() {
if (!query.dictType) {
tableData.value = []
return
}
loading.value = true
try {
const res = await request.get(`/zgapi/v1/admin/dict/type/${query.dictType}`)
let dataList = res.data ?? []
// 如果有 dictLabel 筛选,前端过滤
if (query.dictLabel) {
dataList = dataList.filter((item: DictItem) =>
item.dictLabel.includes(query.dictLabel)
)
}
tableData.value = dataList
} catch {
tableData.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
fetchData()
}
function handleReset() {
query.dictType = ''
query.dictLabel = ''
tableData.value = []
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
let url = '/admin/dict/form'
if (query.dictType) {
url += '?dictType=' + query.dictType
}
router.push(url)
}
function handleEdit(row: DictItem) {
router.push('/admin/dict/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: DictItem) {
ElMessageBox.confirm(`确认删除字典"${row.dictLabel}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/dict/${row.id}`)
ElMessage.success('删除成功')
fetchDictTypeList()
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// --- 新增字典类型 ---
const typeDialogVisible = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref<FormInstance>()
const typeForm = reactive({
dictType: '',
})
const typeRules: FormRules = {
dictType: [{ required: true, message: '请输入字典类型编码', trigger: 'blur' }],
}
function handleAddType() {
typeForm.dictType = ''
setTimeout(() => typeFormRef.value?.clearValidate(), 0)
typeDialogVisible.value = true
}
async function submitAddType() {
const valid = await typeFormRef.value?.validate().catch(() => false)
if (!valid) return
typeSubmitLoading.value = true
try {
await request.post('/zgapi/v1/admin/dict', {
dictType: typeForm.dictType,
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
ElMessage.success('新增字典类型成功')
typeDialogVisible.value = false
fetchDictTypeList()
} catch {
// ignore
} finally {
typeSubmitLoading.value = false
}
}
// 初始化
fetchDictTypeList()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
gap: 8px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="email-page">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline class="search-form">
<el-form-item label="关键词">
<el-input v-model="query.keyword" placeholder="收件人或主题" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width:120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
</el-button-group>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="openSendDialog" v-permission="'system:email:send'">
<el-icon><Promotion /></el-icon>发送邮件
</el-button>
</div>
<div class="toolbar-right">
<el-button @click="fetchData" :icon="RefreshRight" :loading="loading">刷新</el-button>
</div>
</div>
<!-- 发送记录表格 -->
<el-card shadow="never">
<el-table :data="list" v-loading="loading" stripe class="email-table">
<el-table-column prop="toAddress" label="收件人" min-width="180" show-overflow-tooltip />
<el-table-column prop="subject" label="主题" min-width="200" show-overflow-tooltip />
<el-table-column label="格式" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.contentType === 'html' ? '' : 'info'">
{{ row.contentType === 'html' ? 'HTML' : '文本' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 1 ? 'success' : 'danger'" effect="dark">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="发送时间" width="170" />
<el-table-column label="操作" width="120" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button size="small" text @click="showDetail(row)" class="btn-text">
<el-icon><View /></el-icon><span class="btn-label">详情</span>
</el-button>
<el-popconfirm title="确定删除?" @confirm="del(row.id)">
<template #reference>
<el-button size="small" text type="danger" class="btn-text">
<el-icon><Delete /></el-icon><span class="btn-label">删除</span>
</el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无邮件发送记录" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchData"
small
/>
</div>
</el-card>
<!-- 发送弹窗 -->
<el-dialog v-model="sendVisible" title="发送邮件" width="620px" destroy-on-close class="send-dialog">
<el-form :model="sendForm" label-width="70px">
<el-form-item label="收件人" required>
<el-input v-model="sendForm.to" placeholder="user@example.com" />
</el-form-item>
<el-form-item label="主题" required>
<el-input v-model="sendForm.subject" placeholder="邮件主题" />
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="sendForm.contentType">
<el-radio value="html">HTML</el-radio>
<el-radio value="text">纯文本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="sendForm.content" type="textarea" rows="8" placeholder="邮件内容HTML格式支持标签" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sendVisible = false">取消</el-button>
<el-button type="primary" :loading="sending" @click="doSend">发送</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="邮件详情" width="660px" destroy-on-close class="detail-dialog">
<template v-if="detail">
<div class="detail-meta">
<span>收件人{{ detail.toAddress }}</span>
<span>时间{{ detail.createdAt }}</span>
<el-tag size="small" :type="detail.status === 1 ? 'success' : 'danger'" effect="dark">
{{ detail.status === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-subject">{{ detail.subject }}</div>
<div class="detail-body" v-if="detail.contentType === 'html'" v-html="detail.content" />
<pre class="detail-body" v-else>{{ detail.content }}</pre>
<div v-if="detail.errorMsg" class="detail-error">错误{{ detail.errorMsg }}</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, RefreshRight, Promotion, View, Delete } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface EmailItem {
id: string; toAddress: string; subject: string
content: string; contentType: string; status: number
errorMsg: string; createdAt: string
}
const loading = ref(false)
const list = ref<EmailItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
// 搜索条件
const query = reactive({
keyword: '',
status: '' as number | '',
})
const sendVisible = ref(false)
const sending = ref(false)
const sendForm = reactive({ to: '', subject: '', content: '', contentType: 'html' })
const detailVisible = ref(false)
const detail = ref<EmailItem | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/page', {
page: page.value,
pageSize: pageSize.value,
keyword: query.keyword || undefined,
status: query.status !== '' ? query.status : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 拦截器已提示 */ } finally { loading.value = false }
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleReset() {
query.keyword = ''
query.status = ''
page.value = 1
fetchData()
}
function openSendDialog() {
sendForm.to = ''; sendForm.subject = ''; sendForm.content = ''; sendForm.contentType = 'html'
sendVisible.value = true
}
async function doSend() {
if (!sendForm.to || !sendForm.subject || !sendForm.content) {
ElMessage.warning('请填写完整信息'); return
}
sending.value = true
try {
await request.post('/zgapi/v1/admin/email/send', { ...sendForm })
ElMessage.success('邮件已发送')
sendVisible.value = false
fetchData()
} catch { /* 拦截器已提示 */ } finally { sending.value = false }
}
async function showDetail(row: EmailItem) {
try {
const res = await request.get(`/zgapi/v1/admin/email/${row.id}`)
detail.value = res.data
detailVisible.value = true
} catch { /* ignore */ }
}
async function del(id: string) {
try {
await request.delete(`/zgapi/v1/admin/email/${id}`)
ElMessage.success('已删除')
if (list.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
onMounted(() => fetchData())
</script>
<style scoped>
.email-page { max-width: 1100px; }
/* ====== 搜索卡片 ====== */
.search-card { margin-bottom: 16px; }
.card-header-title {
display: flex; align-items: center; gap: 6px;
font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.search-form { margin-bottom: 0; }
.search-form .el-form-item { margin-bottom: 0; }
/* ====== 工具栏 ====== */
.page-toolbar {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 16px;
}
.toolbar-left { display: flex; gap: 8px; }
.toolbar-right { display: flex; gap: 8px; }
/* ====== 表格 ====== */
.email-table { width: 100%; }
.actions { display: flex; gap: 0; justify-content: center; }
.btn-text { padding: 5px 6px; }
/* ====== 分页 ====== */
.pagination-wrapper {
display: flex; justify-content: center; margin-top: 20px;
}
/* ====== 详情弹窗 ====== */
.detail-meta {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color, #e4e7ed);
font-size: 13px; color: var(--text-muted, #909399);
}
.detail-subject {
font-size: 18px; font-weight: 600; color: var(--text-primary, #303133);
margin: 16px 0 12px; word-break: break-all;
}
.detail-body {
background: var(--bg-page, #f5f7fa); padding: 16px;
border-radius: 8px; font-size: 14px; line-height: 1.7;
max-height: 400px; overflow: auto; word-break: break-word;
}
.detail-body pre { white-space: pre-wrap; word-break: break-all; margin: 0; font-family: inherit; }
.detail-error {
margin-top: 12px; padding: 10px; color: #ef4444;
background: #fef2f2; border-radius: 6px; font-size: 13px; word-break: break-all;
}
/* ====== 移动端适配 ====== */
@media (max-width: 768px) {
/* 搜索表单 — 垂直排列 */
.search-form {
display: flex; flex-direction: column; gap: 8px;
}
.search-form .el-form-item {
width: 100%; margin-right: 0; display: block;
}
.search-form .el-input,
.search-form .el-select {
width: 100% !important;
}
.search-form .el-button-group {
width: 100%; display: flex;
}
.search-form .el-button-group .el-button {
flex: 1;
}
/* 工具栏 — 垂直排列 */
.page-toolbar {
flex-direction: column; gap: 10px; align-items: stretch;
}
.toolbar-left, .toolbar-right {
width: 100%;
}
.toolbar-left .el-button,
.toolbar-right .el-button {
width: 100%;
}
/* 表格 — 横向滚动 + 缩小列宽 */
.email-table {
font-size: 13px;
}
/* 操作列 — 手机端只显示图标 */
.btn-label { display: none; }
.btn-text { padding: 5px 4px; }
.actions { gap: 0; }
/* 弹窗 — 占满屏幕 */
:deep(.send-dialog), :deep(.detail-dialog) {
width: 95vw !important; max-width: 95vw;
}
:deep(.send-dialog .el-dialog__body),
:deep(.detail-dialog .el-dialog__body) {
padding: 16px;
}
/* 详情内容适配 */
.detail-subject { font-size: 16px; }
.detail-body {
max-height: 300px; font-size: 13px; padding: 12px;
}
/* 分页居中 */
.pagination-wrapper {
justify-content: center; flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.email-table { font-size: 12px; }
.detail-meta { flex-direction: column; align-items: flex-start; gap: 6px; }
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<div class="file-page">
<!-- 顶部操作栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<el-select v-model="filterType" placeholder="文件类型" clearable style="width:140px" @change="fetchData">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="uploadVisible = true" v-permission="'system:file:upload'">
<el-icon><Upload /></el-icon>上传文件
</el-button>
<el-button @click="fetchData" :icon="RefreshRight">刷新</el-button>
</div>
</div>
<!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" title="上传文件" width="460px" destroy-on-close>
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="文件类型">
<el-select v-model="uploadForm.fileType" style="width:100%">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="选择文件">
<div class="upload-area" @click="triggerUpload" v-if="!uploadForm.file">
<el-icon :size="32" color="#94a3b8"><UploadFilled /></el-icon>
<p>点击选择文件</p>
</div>
<div class="upload-file" v-else>
<el-icon><Document /></el-icon>
<span>{{ uploadForm.file.name }}</span>
<span class="upload-size">{{ formatSize(uploadForm.file.size) }}</span>
<el-button size="small" text type="danger" @click="uploadForm.file = null">
<el-icon><Close /></el-icon>
</el-button>
</div>
<input ref="fileInputRef" type="file" style="display:none" @change="onFileSelected" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" :disabled="!uploadForm.file" @click="doUpload">
开始上传
</el-button>
</template>
</el-dialog>
<!-- 文件网格 -->
<div class="file-grid" v-loading="loading">
<div
v-for="item in fileList"
:key="item.id"
class="file-card"
@click="preview(item)"
>
<!-- 预览区域 -->
<div class="file-preview">
<template v-if="isImage(item.mimeType)">
<img
:src="getFileUrl(item.id)"
:alt="item.fileName"
class="file-thumb"
loading="lazy"
/>
</template>
<template v-else-if="item.fileType === 'doc'">
<el-icon :size="48" color="#6366f1"><Document /></el-icon>
</template>
<template v-else>
<el-icon :size="48" color="#94a3b8"><FolderOpened /></el-icon>
</template>
</div>
<!-- 信息 -->
<div class="file-info">
<div class="file-name" :title="item.fileName">{{ item.fileName }}</div>
<div class="file-meta">
<el-tag size="small" type="info">{{ item.fileType || 'other' }}</el-tag>
<span class="file-size">{{ formatSize(item.fileSize) }}</span>
</div>
</div>
<!-- 操作 -->
<div class="file-actions" @click.stop>
<el-button size="small" text @click="preview(item)">
<el-icon><View /></el-icon>
</el-button>
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(item.id)">
<template #reference>
<el-button size="small" text type="danger">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-popconfirm>
</div>
</div>
</div>
<el-empty v-if="!loading && fileList.length === 0" description="暂无文件" />
<!-- 分页 -->
<div class="page-footer" v-if="total > 0">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchData"
small
/>
</div>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="文件预览" width="720px" destroy-on-close>
<div class="preview-body">
<template v-if="previewItem && isImage(previewItem.mimeType)">
<img :src="getFileUrl(previewItem.id)" style="max-width:100%;max-height:60vh;display:block;margin:0 auto" />
</template>
<template v-else>
<el-empty description="非图片文件,无法预览" />
</template>
<div class="preview-meta" v-if="previewItem">
<div class="meta-line"><span class="meta-label">文件名</span>{{ previewItem.fileName }}</div>
<div class="meta-line"><span class="meta-label">类型</span>{{ previewItem.mimeType || '-' }}</div>
<div class="meta-line"><span class="meta-label">大小</span>{{ formatSize(previewItem.fileSize) }}</div>
<div class="meta-line"><span class="meta-label">上传时间</span>{{ previewItem.createdAt }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { RefreshRight, Document, FolderOpened, View, Delete, Upload, UploadFilled, Close } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface FileItem {
id: string
fileName: string
fileType: string
fileSize: number
mimeType: string
md5: string
createdAt: string
}
const loading = ref(false)
const fileList = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterType = ref('')
const previewVisible = ref(false)
const previewItem = ref<FileItem | null>(null)
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
// ===== 上传 =====
const uploadVisible = ref(false)
const uploading = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const uploadForm = reactive({ fileType: 'other', file: null as File | null })
function triggerUpload() {
fileInputRef.value?.click()
}
function onFileSelected(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) uploadForm.file = file
}
async function doUpload() {
if (!uploadForm.file) return
uploading.value = true
try {
const fd = new FormData()
fd.append('file', uploadForm.file)
fd.append('fileType', uploadForm.fileType)
await request.post('/zgapi/v1/admin/file/upload', fd)
ElMessage.success('上传成功')
uploadVisible.value = false
uploadForm.file = null
fetchData()
} catch { /* ignore */ } finally {
uploading.value = false
}
}
function getFileUrl(id: string) {
return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view`
}
function isImage(mimeType: string | null | undefined) {
return mimeType ? mimeType.startsWith('image/') : false
}
function formatSize(bytes: number | null) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/file/page', {
page: page.value,
pageSize: pageSize.value,
fileType: filterType.value || undefined,
})
fileList.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 拦截器已提示 */ } finally {
loading.value = false
}
}
function preview(item: FileItem) {
previewItem.value = item
previewVisible.value = true
}
async function handleDelete(id: string) {
try {
await request.delete(`/zgapi/v1/admin/file/${id}`)
ElMessage.success('已删除')
if (fileList.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
onMounted(() => fetchData())
</script>
<style scoped>
.file-page { max-width: 1100px; }
/* 工具栏 */
.page-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.toolbar-left { display:flex; gap:12px; }
.page-footer { display:flex; justify-content:center; margin-top:24px; }
/* 文件网格 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.file-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
}
.file-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
/* 预览区 */
.file-preview {
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-page);
overflow: hidden;
}
.file-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 信息区 */
.file-info {
padding: 12px;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.file-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.file-size {
font-size: 12px;
color: var(--text-muted);
}
/* 操作 */
.file-actions {
display: flex;
justify-content: flex-end;
padding: 0 8px 8px;
gap: 4px;
}
/* 上传弹窗 */
.upload-area {
width: 100%; height: 120px;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
cursor: pointer; transition: all var(--transition-fast);
color: var(--text-muted); font-size: 13px; gap: 8px;
}
.upload-area:hover { border-color: var(--color-primary); background: var(--color-primary-bg); }
.upload-file {
display: flex; align-items: center; gap: 10px;
padding: 12px; background: var(--bg-page); border-radius: var(--radius-md);
font-size: 13px;
}
.upload-size { color: var(--text-muted); font-size: 12px; margin-left: auto; }
/* 移动端 */
@media (max-width: 768px) {
.file-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.file-preview { height: 120px; }
.file-name { font-size: 12px; }
.page-toolbar { flex-direction: column; gap: 10px; }
}
/* 预览弹窗 */
.preview-meta {
margin-top: 20px;
padding: 16px;
background: var(--bg-page);
border-radius: var(--radius-md);
}
.meta-line {
font-size: 13px;
color: var(--text-secondary);
line-height: 2;
}
.meta-label {
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="登录状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="loginIp" label="登录IP" width="150" />
<el-table-column prop="createdAt" label="登录时间" width="180">
<template #default="{ row }">
{{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="browser" label="浏览器" min-width="140" show-overflow-tooltip />
<el-table-column prop="os" label="操作系统" min-width="150" show-overflow-tooltip />
<el-table-column prop="failReason" label="失败原因" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.failReason || '-' }}
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无登录日志" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 登录日志类型 */
interface LoginLogItem {
username: string
loginIp: string
loginTime: string
status: number
browser: string
os: string
failReason: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
status: '' as string | number,
dateRange: [] as string[],
})
const tableData = ref<LoginLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.status !== '') params.status = query.status
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/login/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.status = ''
query.dateRange = []
query.page = 1
fetchData()
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-date-picker { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="tokenlog-page">
<div class="page-toolbar">
<el-select v-model="filterConsumed" placeholder="消费状态" clearable style="width:140px" @change="fetchData">
<el-option label="未消费" :value="0" />
<el-option label="已消费" :value="1" />
</el-select>
<el-button @click="fetchData" :icon="RefreshRight">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="systemCode" label="系统编码" width="120" />
<el-table-column prop="tokenPreview" label="Token" min-width="220" show-overflow-tooltip />
<el-table-column label="消费状态" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.consumed === 1 ? 'success' : 'warning'">
{{ row.consumed === 1 ? '已消费' : '未消费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="acquiredAt" label="获取时间" width="170" />
<el-table-column prop="consumedAt" label="消费时间" width="170">
<template #default="{ row }">{{ row.consumedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="consumeApi" label="消费接口" width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.consumeApi || '-' }}</template>
</el-table-column>
<el-table-column prop="consumeIp" label="消费IP" width="140">
<template #default="{ row }">{{ row.consumeIp || '-' }}</template>
</el-table-column>
</el-table>
<div class="page-footer" v-if="total > 0">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="prev,pager,next" @current-change="fetchData" small />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RefreshRight } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface TokenLog {
id: string; systemCode: string; tokenPreview: string
consumed: number; acquiredAt: string; consumedAt: string
consumeApi: string; consumeIp: string
}
const loading = ref(false)
const list = ref<TokenLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const filterConsumed = ref<number | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/token-log/page', {
page: page.value, pageSize: pageSize.value,
consumed: filterConsumed.value !== null ? filterConsumed.value : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch {} finally { loading.value = false }
}
onMounted(() => fetchData())
</script>
<style scoped>
.tokenlog-page { max-width: 1100px; }
.page-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.page-footer { display:flex; justify-content:center; margin-top:24px; }
@media (max-width: 768px) {
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="操作模块">
<el-input v-model="query.module" placeholder="请输入操作模块" clearable />
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="query.operateType" placeholder="全部" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="新增" value="INSERT" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
<el-option label="查询" value="SELECT" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-detail">
<div class="detail-section" v-if="row.requestBody">
<h4>请求体</h4>
<pre>{{ formatJson(row.requestBody) }}</pre>
</div>
<div class="detail-section" v-if="row.errorMsg">
<h4>错误信息</h4>
<pre class="error-msg">{{ row.errorMsg }}</pre>
</div>
<div v-if="!row.requestBody && !row.errorMsg" class="empty-detail">暂无详情</div>
</div>
</template>
</el-table-column>
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="模块" width="140" show-overflow-tooltip />
<el-table-column prop="operateType" label="操作类型" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="operateTypeTag(row.operateType)"
size="small"
effect="dark"
>
{{ row.operateType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="requestUrl" label="请求地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="requestMethod" label="请求方式" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="methodTag(row.requestMethod)"
size="small"
effect="dark"
>
{{ row.requestMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="responseStatus" label="响应状态码" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="row.responseStatus === 200 ? 'success' : 'danger'"
size="small"
effect="dark"
>
{{ row.responseStatus ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.costTime > 1000 ? 'warning' : row.costTime > 500 ? '' : 'success'"
size="small"
>
{{ row.costTime ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operateTime" label="操作时间" width="180">
<template #default="{ row }">
{{ row.operateTime ? new Date(row.operateTime).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无操作日志" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 操作日志类型 */
interface OperationLogItem {
username: string
module: string
operateType: string
requestUrl: string
requestMethod: string
requestBody?: string
responseStatus: number
costTime: number
operateTime: string
errorMsg?: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
module: '',
operateType: '',
dateRange: [] as string[],
})
const tableData = ref<OperationLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.module) params.module = query.module
if (query.operateType) params.operateType = query.operateType
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/operation/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.module = ''
query.operateType = ''
query.dateRange = []
query.page = 1
fetchData()
}
/** 操作类型 tag 颜色 */
function operateTypeTag(type: string) {
const map: Record<string, string> = {
INSERT: 'success',
UPDATE: 'warning',
DELETE: 'danger',
SELECT: '',
OTHER: 'info',
}
return map[type] || 'info'
}
/** 请求方式 tag 颜色 */
function methodTag(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
}
return map[method] || 'info'
}
/** 格式化 JSON */
function formatJson(str: string) {
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.expand-detail {
padding: 16px 28px;
}
.detail-section {
margin-bottom: 14px;
}
.detail-section h4 {
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.detail-section pre {
background: #f5f7fa;
padding: 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid #ebeef5;
}
.error-msg {
color: #f56c6c;
}
.empty-detail {
color: #c0c4cc;
text-align: center;
padding: 20px 0;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-date-picker { width: 100% !important; }
.expand-detail { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,325 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑菜单' : '新增菜单'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<!-- 所属系统新增时可切换编辑时展示不可修改 -->
<el-form-item label="所属系统" prop="systemId">
<el-select
v-model="form.systemId"
placeholder="请选择系统"
:disabled="isEdit"
style="width: 100%"
@change="onSystemChange"
>
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="上级菜单">
<el-tree-select
v-model="form.parentId"
:data="parentTreeData"
:props="{ value: 'id', label: 'menuName', children: 'children' }"
node-key="id"
placeholder="请选择上级菜单(留空为顶级)"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 按钮类型只需权限标识 -->
<template v-if="form.menuType === 3">
<el-divider content-position="left">
<span class="form-section-title">按钮权限</span>
</el-divider>
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<!-- 目录/菜单类型显示路由和图标 -->
<template v-else>
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="路由路径" :prop="form.menuType !== 1 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="权限标识" v-if="form.menuType === 2">
<el-input v-model="form.permission" placeholder="可选,按钮权限标识" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
interface SystemItem {
id: string
systemId: string
systemName: string
}
const iconList = [
'HomeFilled','Monitor','User','UserFilled','Document','Notebook','Tickets',
'Setting','Menu','List','Link','Collection','Avatar','DataLine','Connection',
'Switch','SwitchButton','ArrowRight','ArrowDown','Search','Plus','Edit',
'Delete','Check','Close','CircleClose','Remove','Expand','Fold',
'Key','Lock','InfoFilled','CircleCheckFilled',
]
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表 */
const systemList = ref<SystemItem[]>([])
/** 上级菜单树 */
const parentTreeData = ref<MenuNode[]>([])
/** 表单数据 */
const form = reactive({
systemId: undefined as string | undefined,
parentId: null as string | null,
menuName: '',
menuPath: '',
component: '',
icon: '',
menuType: 2,
permission: '',
sortOrder: 0,
status: 1,
})
/** 校验规则(按钮类型需要权限标识,目录/菜单需要路由路径) */
const rules = computed<FormRules>(() => ({
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3
? [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
: [],
menuPath: form.menuType === 2
? [{ required: true, message: '请输入路由路径', trigger: 'blur' }]
: [],
menuType: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
/** 加载系统列表 */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载菜单树(用于上级菜单选择) */
async function loadMenuTree(systemId: string) {
if (!systemId) {
parentTreeData.value = []
return
}
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId } })
parentTreeData.value = res.data ?? []
} catch {
parentTreeData.value = []
}
}
/** 切换系统时重新加载菜单树 */
function onSystemChange(systemId: string | undefined) {
form.parentId = null
if (systemId) {
loadMenuTree(systemId)
} else {
parentTreeData.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
// 新增模式,从 query 读取预设参数
isEdit.value = false
const querySystemId = route.query.systemId as string | undefined
const queryParentId = route.query.parentId as string | undefined
if (querySystemId) {
form.systemId = querySystemId
loadMenuTree(form.systemId)
}
if (queryParentId) {
form.parentId = queryParentId
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/menu/${editId.value}`)
const data = res.data
if (data) {
form.systemId = data.systemId
// 先加载菜单树,再赋值其他字段(确保 tree-select 数据就绪)
if (data.systemId) {
await loadMenuTree(data.systemId)
}
Object.assign(form, {
parentId: data.parentId ?? null,
menuName: data.menuName ?? '',
menuPath: data.menuPath ?? '',
component: data.component ?? '',
icon: data.icon ?? '',
menuType: data.menuType ?? 2,
permission: data.permission ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载菜单信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/menu/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/menu', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="page-wrapper">
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><Menu /></el-icon>
<span>菜单管理</span>
</div>
</template>
<div class="menu-toolbar">
<span class="toolbar-label">选择系统</span>
<el-select v-model="currentSystemId" placeholder="请选择系统" style="width: 240px" @change="fetchMenuTree">
<el-option v-for="sys in systemList" :key="sys.systemId" :label="sys.systemName" :value="sys.systemId" />
</el-select>
<el-button type="primary" @click="openForm(null)" :disabled="!currentSystemId">
<el-icon><Plus /></el-icon>新增菜单
</el-button>
<el-button @click="toggleExpand">
<el-icon><Expand /></el-icon>
{{ isExpandAll ? '折叠全部' : '展开全部' }}
</el-button>
</div>
</el-card>
<el-card shadow="never">
<el-table ref="tableRef" :data="menuTreeData" border row-key="id"
:tree-props="{ children: 'children' }" :default-expand-all="true" v-loading="loading" style="width:100%">
<el-table-column prop="menuName" label="菜单名称" width="200" />
<el-table-column prop="icon" label="图标" width="80" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:18px;height:18px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="18"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" width="180" show-overflow-tooltip />
<el-table-column prop="component" label="组件" width="180" show-overflow-tooltip />
<el-table-column prop="menuType" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.menuType === 1 ? '' : row.menuType === 2 ? 'success' : 'warning'" effect="dark" size="small">
{{ menuTypeMap[row.menuType] ?? '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="permission" label="权限标识" width="160" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="openForm(row)">新增子级</el-button>
<el-button type="primary" size="small" plain @click="openForm(null, row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 表单弹窗 -->
<el-dialog v-model="dlg.visible" :title="dlg.title" width="600px" :close-on-click-modal="false" @closed="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="上级菜单" v-if="!dlg.isEdit">
<el-tree-select v-model="form.parentId" :data="parentTree" :props="{ value:'id', label:'menuName', children:'children' }"
node-key="id" placeholder="留空为顶级菜单" clearable check-strictly style="width:100%" />
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="form.menuType === 3">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="路由路径" :prop="form.menuType === 2 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg.visible = false">取消</el-button>
<el-button type="primary" :loading="dlg.loading" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Expand, Menu } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const menuTypeMap: Record<number, string> = { 1: '目录', 2: '菜单', 3: '按钮' }
interface MenuItem {
id?: string; parentId: string | null; menuName: string; menuPath: string; path?: string
component: string; icon: string; menuType: number; permission: string
sortOrder: number; status: number; children?: MenuItem[]
}
// 系统列表
const systemList = ref<any[]>([])
const currentSystemId = ref<string>()
const menuTreeData = ref<MenuItem[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const tableRef = ref()
const parentTree = ref<any[]>([])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
// 默认选 gateway 系统
const gw = systemList.value.find((s: any) => s.systemCode === 'gateway')
if (gw) { currentSystemId.value = gw.systemId; await fetchMenuTree() }
} catch { /* ignore */ }
})
async function fetchMenuTree() {
if (!currentSystemId.value) { menuTreeData.value = []; return }
loading.value = true
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: currentSystemId.value } })
menuTreeData.value = res.data ?? []
parentTree.value = res.data ?? []
} catch { menuTreeData.value = [] } finally { loading.value = false }
}
function toggleExpand() {
isExpandAll.value = !isExpandAll.value
const t = tableRef.value; if (!t) return
const toggle = (rows: any[]) => rows.forEach(r => { t.toggleRowExpansion(r, isExpandAll.value); if (r.children?.length) toggle(r.children) })
toggle(menuTreeData.value)
}
// 表单弹窗
const dlg = reactive({ visible: false, title: '', isEdit: false, loading: false })
const formRef = ref<FormInstance>()
const editId = ref<string>()
const form = reactive({ parentId: null as string | null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
const rules = computed<FormRules>(() => ({
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3 ? [{ required: true, message: '请输入权限标识', trigger: 'blur' }] : [],
menuPath: form.menuType === 2 ? [{ required: true, message: '请输入路由路径', trigger: 'blur' }] : [],
}))
function resetForm() {
Object.assign(form, { parentId: null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
editId.value = undefined
// 清除校验状态,避免弹窗立即显示错误
setTimeout(() => formRef.value?.clearValidate(), 0)
}
function openForm(parent: MenuItem | null, edit?: MenuItem) {
resetForm()
dlg.visible = true
if (edit) {
// 编辑模式
dlg.title = '编辑菜单'
dlg.isEdit = true
editId.value = edit.id
Object.assign(form, {
menuName: edit.menuName, menuType: edit.menuType, menuPath: edit.path || '',
component: edit.component || '', icon: edit.icon || '',
permission: edit.permission || '', sortOrder: edit.sortOrder, status: edit.status,
})
} else {
dlg.title = parent ? '新增子菜单' : '新增菜单'
dlg.isEdit = false
if (parent) form.parentId = parent.id || null
}
}
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
dlg.loading = true
try {
const payload: any = { ...form, systemId: currentSystemId.value }
if (editId.value) {
await request.put('/zgapi/v1/admin/menu', { ...payload, id: editId.value })
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/menu', payload)
ElMessage.success('新增成功')
}
dlg.visible = false
fetchMenuTree()
} catch { /* ignore */ } finally { dlg.loading = false }
}
function handleDelete(row: MenuItem) {
ElMessageBox.confirm(`确认删除菜单"${row.menuName}"吗?`, '删除确认', { type: 'warning' }).then(async () => {
try { await request.delete(`/zgapi/v1/admin/menu/${row.id}`); ElMessage.success('删除成功'); fetchMenuTree() } catch { /* ignore */ }
}).catch(() => {})
}
</script>
<style scoped>
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
.card-header-title { display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; color:var(--text-primary); }
.menu-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.toolbar-label { font-size:14px; font-weight:500; color:var(--text-secondary); }
.actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; }
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
.menu-toolbar { flex-direction: column; align-items: stretch; }
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑角色' : '新增角色'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="form.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码" prop="roleCode">
<el-input v-model="form.roleCode" placeholder="请输入角色编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface SystemItem {
id: string
systemId: string
systemName: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表(下拉用) */
const systemList = ref<SystemItem[]>([])
/** 表单数据 */
const form = reactive({
roleName: '',
roleCode: '',
systemId: undefined as string | undefined,
description: '',
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载系统列表 */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/role/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
roleName: data.roleName ?? '',
roleCode: data.roleCode ?? '',
systemId: data.systemId,
description: data.description ?? '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载角色信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/role/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/role', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,315 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="角色名称">
<el-input v-model="query.roleName" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="全部" clearable style="width: 180px">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="roleName" label="角色名称" width="140" />
<el-table-column prop="roleCode" label="角色编码" width="140" />
<el-table-column prop="systemName" label="所属系统" width="150" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="310" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
<el-button type="success" size="small" plain @click="handleAssignMenu(row)">分配菜单</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无角色数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 分配菜单弹窗 -->
<el-dialog
v-model="menuVisible"
title="分配菜单"
width="500px"
:close-on-click-modal="false"
>
<el-tree
ref="menuTreeRef"
:data="menuTreeData"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'menuName', children: 'children' }"
style="max-height: 400px; overflow-y: auto"
/>
<template #footer>
<el-button @click="menuVisible = false">取消</el-button>
<el-button type="primary" :loading="menuSubmitLoading" @click="submitAssignMenu">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import type { ElTree } from 'element-plus'
import request from '@/utils/request'
/** 角色类型 */
interface RoleItem {
id?: string
roleName: string
roleCode: string
systemId: string | undefined
systemName?: string
description: string
status: number
}
interface SystemItem {
id: string
systemId: string
systemName: string
}
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
roleName: '',
systemId: undefined as string | undefined,
})
const tableData = ref<RoleItem[]>([])
const total = ref(0)
const loading = ref(false)
const systemList = ref<SystemItem[]>([])
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
// ignore
}
}
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.roleName) params.roleName = query.roleName
if (query.systemId != null) params.systemId = query.systemId
const res = await request.post('/zgapi/v1/admin/role/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.roleName = ''
query.systemId = undefined
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/role/form')
}
function handleEdit(row: RoleItem) {
router.push('/admin/role/form/' + row.id)
}
function handleDelete(row: RoleItem) {
ElMessageBox.confirm(`确认删除角色"${row.roleName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/role/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// --- 分配菜单 ---
const menuVisible = ref(false)
const menuTreeData = ref<MenuNode[]>([])
const menuSubmitLoading = ref(false)
const menuRoleId = ref<string>()
const menuTreeRef = ref<InstanceType<typeof ElTree>>()
async function handleAssignMenu(row: RoleItem) {
menuRoleId.value = row.id
const sysId = row.systemId
if (!sysId) {
ElMessage.warning('该角色未关联系统,无法分配菜单')
return
}
menuVisible.value = true
menuTreeData.value = []
// 并行加载菜单树 + 已分配的菜单ID
try {
const [treeRes, checkedRes] = await Promise.all([
request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: sysId } }),
request.get(`/zgapi/v1/admin/role/${row.id}/menu`),
])
menuTreeData.value = treeRes.data ?? []
// 预选已分配的菜单
const checkedIds: string[] = checkedRes.data ?? []
// 等 DOM 更新后设置勾选
await new Promise(r => setTimeout(r, 50))
menuTreeRef.value?.setCheckedKeys(checkedIds)
} catch {
ElMessage.error('加载菜单树失败')
}
}
async function submitAssignMenu() {
const checkedKeys = menuTreeRef.value?.getCheckedKeys() as string[]
const halfCheckedKeys = menuTreeRef.value?.getHalfCheckedKeys() as string[]
const menuIds = [...checkedKeys, ...halfCheckedKeys]
menuSubmitLoading.value = true
try {
await request.post(`/zgapi/v1/admin/role/${menuRoleId.value}/menu`, menuIds)
ElMessage.success('分配菜单成功')
menuVisible.value = false
} catch {
// ignore
} finally {
menuSubmitLoading.value = false
}
}
// 初始化
fetchData()
fetchSystemList()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑接口' : '新增接口'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="s in systemOptions"
:key="s.id"
:label="s.systemName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径" prop="apiPath">
<el-input v-model="form.apiPath" placeholder="请输入接口路径,如 /zgapi/v1/admin/user" />
</el-form-item>
<el-form-item label="请求方式" prop="apiMethod">
<el-select v-model="form.apiMethod" placeholder="请选择请求方式" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="apiName">
<el-input v-model="form.apiName" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口描述" prop="apiDesc">
<el-input v-model="form.apiDesc" type="textarea" :rows="2" placeholder="请输入接口描述(可选)" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const systemOptions = ref<SystemOption[]>([])
/** 表单数据 */
const form = reactive({
systemId: null as string | null,
apiPath: '',
apiMethod: '',
apiName: '',
apiDesc: '',
status: 1,
})
/** 表单校验规则 */
const rules: FormRules = {
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
apiPath: [{ required: true, message: '请输入接口路径', trigger: 'blur' }],
apiMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
apiName: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
}
/** 加载系统列表 */
async function loadSystems() {
try {
const res = await request.post('/zgapi/v1/admin/system/page', { page: 1, pageSize: 100, status: 1 })
systemOptions.value = res.data?.records ?? []
} catch {
// 错误由拦截器处理
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式:从 query 参数取 systemId
const sysId = route.query.systemId as string | undefined
if (sysId) {
form.systemId = sysId
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/system/api/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
systemId: data.systemId ?? null,
apiPath: data.apiPath ?? '',
apiMethod: data.apiMethod ?? '',
apiName: data.apiName ?? '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载接口信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/system/api/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system/api', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadSystems()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="请选择系统" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="s in systemOptions"
:key="s.id"
:label="s.systemName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径">
<el-input v-model="query.apiPath" placeholder="请输入接口路径" clearable />
</el-form-item>
<el-form-item label="请求方式">
<el-select v-model="query.apiMethod" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增接口
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="apiPath" label="接口路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="apiMethod" label="请求方式" width="100" align="center">
<template #default="{ row }">
<el-tag :type="methodTagType(row.apiMethod)" effect="dark" size="small">
{{ row.apiMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="apiName" label="接口名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="apiDesc" label="接口描述" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无接口数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 接口类型 */
interface ApiItem {
id?: string
systemId: string
apiPath: string
apiMethod: string
apiName: string
status: number
}
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemId: undefined as string | undefined,
apiPath: '',
apiMethod: '',
})
const tableData = ref<ApiItem[]>([])
const total = ref(0)
const loading = ref(false)
const systemOptions = ref<SystemOption[]>([])
// --- 加载系统列表(用于下拉框) ---
async function loadSystems() {
try {
const res = await request.post('/zgapi/v1/admin/system/page', { page: 1, pageSize: 100, status: 1 })
systemOptions.value = res.data?.records ?? []
// 从 localStorage 取当前用户所在系统,设为默认选中
const sysId = localStorage.getItem('currentSystemId')
if (sysId) {
query.systemId = sysId
}
} catch {
// 错误由拦截器处理
}
}
// --- 请求方式对应 el-tag 颜色 ---
function methodTagType(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'warning',
PUT: 'primary',
DELETE: 'danger',
}
return map[method] || 'info'
}
// --- 数据加载 ---
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemId != null) params.systemId = query.systemId
if (query.apiPath) params.apiPath = query.apiPath
if (query.apiMethod) params.apiMethod = query.apiMethod
const res = await request.post('/zgapi/v1/admin/system/api/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemId = undefined
query.apiPath = ''
query.apiMethod = ''
query.page = 1
fetchData()
}
// --- 新增/编辑 ---
const router = useRouter()
function handleAdd() {
// 携带当前选中的 systemId
const query: Record<string, string> = {}
if (query.systemId != null) {
query.systemId = String(query.systemId)
}
router.push({ path: '/admin/api/form', query })
}
function handleEdit(row: ApiItem) {
router.push('/admin/api/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: ApiItem) {
ElMessageBox.confirm(`确认删除接口"${row.apiPath}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/api/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// 错误由拦截器处理
}
}).catch(() => {})
}
// 初始化
onMounted(() => {
loadSystems().then(() => {
fetchData()
})
})
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑系统' : '新增系统'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="系统编码" prop="systemCode">
<el-input v-model="form.systemCode" placeholder="请输入系统编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="系统名称" prop="systemName">
<el-input v-model="form.systemName" placeholder="请输入系统名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="前端地址" prop="frontUrl">
<el-input v-model="form.frontUrl" placeholder="请输入前端地址" />
</el-form-item>
<el-form-item label="后端地址">
<el-input v-model="form.backendUrl" placeholder="请输入后端地址" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 表单数据 */
const form = reactive({
systemCode: '',
systemName: '',
frontUrl: '',
backendUrl: '',
icon: '',
sortOrder: 0,
status: 1,
description: '',
})
/** 表单校验规则 */
const rules: FormRules = {
systemCode: [{ required: true, message: '请输入系统编码', trigger: 'blur' }],
systemName: [{ required: true, message: '请输入系统名称', trigger: 'blur' }],
frontUrl: [{ required: true, message: '请输入前端地址', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/system/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
systemCode: data.systemCode ?? '',
systemName: data.systemName ?? '',
frontUrl: data.frontUrl ?? '',
backendUrl: data.backendUrl ?? '',
icon: data.icon ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
description: data.description ?? '',
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载系统信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put('/zgapi/v1/admin/system', { ...form, id: editId.value })
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="系统编码">
<el-input v-model="query.systemCode" placeholder="请输入系统编码" clearable />
</el-form-item>
<el-form-item label="系统名称">
<el-input v-model="query.systemName" placeholder="请输入系统名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:system:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增系统
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="systemCode" label="系统编码" width="140" />
<el-table-column prop="systemName" label="系统名称" width="160" />
<el-table-column prop="icon" label="图标" width="70" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:20px;height:20px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="20"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="frontUrl" label="前端地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="backendUrl" label="后端真实地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="290" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
<el-button type="success" size="small" plain @click="openDoc(row)">文档</el-button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无系统数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
/** 系统类型 */
interface SystemItem {
id?: string
systemCode: string
systemName: string
frontUrl: string
backendUrl: string
icon: string
description: string
docToken: string
status: number
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemCode: '',
systemName: '',
status: '' as string | number,
})
const tableData = ref<SystemItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemCode) params.systemCode = query.systemCode
if (query.systemName) params.systemName = query.systemName
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/system/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemCode = ''
query.systemName = ''
query.status = ''
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/system/form')
}
function openDoc(row: SystemItem) {
window.open('/docs/' + row.docToken, '_blank')
}
function handleEdit(row: SystemItem) {
router.push('/admin/system/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: SystemItem) {
ElMessageBox.confirm(`确认删除系统"${row.systemName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// 错误由拦截器处理
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="form-page">
<el-page-header @back="handleBack" :title="isEdit ? '编辑用户' : '新增用户'" />
<el-divider />
<el-card shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left"><span class="section-label">基本信息</span></el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- ===== 系统 & 角色绑定仅编辑模式 ===== -->
<template v-if="isEdit">
<el-divider content-position="left"><span class="section-label">系统绑定</span></el-divider>
<el-form-item label="绑定系统">
<el-checkbox-group v-model="bindSystemIds" @change="onSystemChange">
<el-checkbox
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemId"
>
{{ sys.systemName }}
<el-tag size="small" type="info" style="margin-left:4px">{{ sys.systemCode }}</el-tag>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-divider content-position="left"><span class="section-label">角色分配</span></el-divider>
<p class="hint-text">先选择系统再为该用户分配该系统下的角色</p>
<el-form-item label="选择系统">
<el-select
v-model="roleSystemId"
placeholder="请选择系统"
clearable
style="width:280px"
@change="onRoleSystemChange"
>
<el-option
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemName + ' (' + sys.systemCode + ')'"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item v-if="roleSystemId" label="分配角色">
<el-checkbox-group v-model="assignRoleIds">
<el-checkbox
v-for="r in currentRoleList"
:key="r.id"
:label="r.id"
>
{{ r.roleName }}
<span style="color:var(--text-muted);font-size:12px;margin-left:4px">{{ r.roleCode }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="currentRoleList.length === 0" description="该系统下暂无角色" :image-size="60" />
</el-form-item>
</template>
<!-- ===== 按钮 ===== -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
realName: '',
email: '',
phone: '',
post: '',
password: '',
status: 1,
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
realName: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: isEdit.value ? [] : [{ required: true, message: '请输入密码', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
// ===== 系统绑定 =====
const allSystems = ref<any[]>([])
const bindSystemIds = ref<string[]>([])
async function fetchAllSystems() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
allSystems.value = res.data ?? []
} catch { /* ignore */ }
}
async function fetchUserSystems() {
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/system`)
bindSystemIds.value = res.data ?? []
} catch { /* ignore */ }
}
async function onSystemChange(_val: string[]) {
// 用户取消勾选系统时,清除该系统下的角色选择
await saveSystems()
}
async function saveSystems() {
if (!editId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/system`, bindSystemIds.value)
} catch { /* ignore */ }
}
// ===== 角色分配 =====
const roleSystemId = ref<string>()
const assignRoleIds = ref<string[]>([])
const currentRoleList = ref<any[]>([])
async function onRoleSystemChange(sysId: string | undefined) {
assignRoleIds.value = []
if (!sysId) { currentRoleList.value = []; return }
// 加载该系统下的角色列表
try {
const res = await request.post('/zgapi/v1/admin/role/page', { page: 1, pageSize: 999, systemId: sysId })
currentRoleList.value = res.data?.records ?? []
} catch {
currentRoleList.value = []
}
// 加载用户在该系统下的已有角色
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/role/${sysId}`)
assignRoleIds.value = res.data ?? []
} catch { /* ignore */ }
}
async function saveRoles() {
if (!editId.value || !roleSystemId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/role`, assignRoleIds.value, {
params: { systemId: roleSystemId.value }
})
ElMessage.success('角色已保存')
} catch { /* ignore */ }
}
// ===== 加载与提交 =====
async function loadData() {
const id = route.params.id as string
if (!id) { isEdit.value = false; return }
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
username: data.username ?? '',
realName: data.realName ?? '',
email: data.email ?? '',
phone: data.phone ?? '',
post: data.post ?? '',
password: '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载用户信息失败')
router.back()
}
}
function handleBack() { router.back() }
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
const payload: any = {
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
status: form.status,
}
if (isEdit.value) {
await request.put('/zgapi/v1/admin/user', { ...payload, id: editId.value })
// 保存系统绑定
await saveSystems()
// 如果有选择角色系统,保存角色
if (roleSystemId.value) await saveRoles()
ElMessage.success('用户信息已保存')
} else {
await request.post('/zgapi/v1/admin/user', { ...payload, password: form.password })
ElMessage.success('新增成功')
}
router.back()
} catch (e: any) {
const msg = e?.response?.data?.msg || e?.message
if (msg && msg !== '请求失败') ElMessage.error(msg)
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchAllSystems()
await loadData()
if (isEdit.value) {
await fetchUserSystems()
}
})
</script>
<style scoped>
.form-page { display:flex; flex-direction:column; }
:deep(.el-page-header) { margin-bottom:4px; }
:deep(.el-page-header + .el-divider) { margin:0 0 20px; }
.form-card { max-width:860px; margin:0 auto; width:100%; }
.section-label { font-size:15px; font-weight:600; color:var(--text-primary); }
.hint-text { font-size:13px; color:var(--text-muted); margin-bottom:16px; }
.form-footer { display:flex; justify-content:flex-end; gap:12px; }
@media (max-width: 768px) {
.form-card { max-width: 100%; padding: 0 8px; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="query.email" placeholder="请输入邮箱" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-button-group>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realName" label="真实姓名" width="100" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginTime" label="最后登录时间" width="180">
<template #default="{ row }">
{{ row.lastLoginTime ? new Date(row.lastLoginTime).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无用户数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 用户类型 */
interface UserItem {
id?: string
username: string
realName: string
email: string
phone: string
post: string
password?: string
status: number
lastLoginTime?: string
}
interface SystemItem {
id: string
systemName: string
}
interface RoleItem {
id: string
roleName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
email: '',
status: '' as string | number,
})
const tableData = ref<UserItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.email) params.email = query.email
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/user/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.email = ''
query.status = ''
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/user/form')
}
function handleEdit(row: UserItem) {
router.push('/admin/user/form/' + row.id)
}
function handleDelete(row: UserItem) {
ElMessageBox.confirm(`确认删除用户"${row.username}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/user/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.search-card {
border-radius: var(--radius-lg);
}
.search-card :deep(.el-card__header) {
padding: 14px 20px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,627 @@
<template>
<div class="doc-page">
<div class="doc-container">
<!-- ====== 页头 ====== -->
<header class="doc-hero">
<div class="doc-hero-left">
<svg class="doc-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<div>
<h1>{{ sys?.systemName || '系统' }} 接入文档</h1>
<p>Mokee Gateway 统一登录网关 · 系统对接完整指南</p>
</div>
</div>
<div class="doc-hero-right">
<el-tag size="large" type="primary" effect="dark" round>系统编码: {{ sys?.systemCode }}</el-tag>
</div>
</header>
<!-- ====== 系统信息 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">1</span>当前系统配置</h2>
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="系统编码"><code class="inline-code">{{ sys?.systemCode }}</code></el-descriptions-item>
<el-descriptions-item label="系统名称">{{ sys?.systemName }}</el-descriptions-item>
<el-descriptions-item label="前端地址"><code class="inline-code">{{ sys?.frontUrl }}</code></el-descriptions-item>
<el-descriptions-item label="后端地址"><code class="inline-code">{{ sys?.backendUrl }}</code></el-descriptions-item>
<el-descriptions-item label="系统描述" :span="2">{{ sys?.description || '-' }}</el-descriptions-item>
</el-descriptions>
</section>
<!-- ====== 架构概览 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">2</span>架构概览</h2>
<div class="arch-diagram">
<pre class="arch-text">
业务前端 Mokee Gateway 业务后端
(Vue/React) gw.server.zgitm.com (任意语言)
携带 Token 验证 Token 从请求头读取:
+ SystemCode 识别系统编码 X-User-Id
转发请求 X-User-Name
注入用户信息头 X-System-Code
</pre>
</div>
<div class="flow-steps">
<div class="flow-step"><b></b> 用户访问业务前端 Token 跳转统一登录页</div>
<div class="flow-step"><b></b> 登录成功 获得 JWT Token 回跳到业务前端</div>
<div class="flow-step"><b></b> 业务前端所有 API 请求 网关 (携带 Authorization + X-System-Code)</div>
<div class="flow-step"><b></b> 网关验证 Token 注入用户信息请求头 转发到业务后端</div>
<div class="flow-step"><b></b> 业务后端从请求头读取用户信息 执行业务逻辑</div>
</div>
<div class="arch-warnings">
<div class="warn-item">
<b>🔴 关键规则一前端只调网关</b>
<p>业务前端的 <code>baseURL</code> 必须配置为 <b>网关地址</b> <code>{{ gatewayUrl }}</code><b>绝对不能</b>直接调用业务后端网关根据请求头 <code>X-System-Code</code> 自动识别并转发到对应后端</p>
</div>
<div class="warn-item">
<b>🔴 关键规则二后端必须开跨域 (CORS)</b>
<p>业务后端需要允许来自网关域名的跨域请求网关转发请求时 origin 是浏览器地址不是网关地址</p>
<p><b>Java Spring Boot 配置</b></p>
<div class="code-box"><pre><code>@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}</code></pre></div>
</div>
<div class="warn-item">
<b>🔴 关键规则三业务后端不再做登录/鉴权</b>
<p>Token 验证由网关完成业务后端只需从请求头读取用户信息不需要再验证 Token不需要查询用户表</p>
</div>
</div>
</section>
<!-- ====== 前端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">3</span>前端接入 (完整代码)</h2>
<h3 class="section-h3">3.1 安装依赖</h3>
<div class="code-box">
<div class="code-label">Shell</div>
<pre><code>npm install axios</code></pre>
</div>
<h3 class="section-h3">3.2 环境变量 (.env)</h3>
<div class="code-box">
<div class="code-label">.env</div>
<pre><code>VITE_GATEWAY_URL = {{ gatewayUrl }}
VITE_LOGIN_URL = {{ loginUrl }}
VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}</code></pre>
</div>
<h3 class="section-h3">3.3 Axios 实例 (src/utils/request.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import axios from 'axios'
import { ElMessage } from 'element-plus'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const request = axios.create({
baseURL: import.meta.env.VITE_GATEWAY_URL,
timeout: 30000,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器401 自动跳转登录
request.interceptors.response.use(
res => res.data.code === 200 ? res.data : Promise.reject(res.data),
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export default request</code></pre>
</div>
<h3 class="section-h3">3.4 路由守卫 (src/router/index.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import { createRouter, createWebHistory } from 'vue-router'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const router = createRouter({
history: createWebHistory(),
routes: [ /* 你的路由 */ ],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (!token && to.path !== '/login') {
// 无 Token → 跳统一登录页
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router</code></pre>
</div>
<h3 class="section-h3">3.5 登录成功回调处理</h3>
<p class="text-p">统一登录成功后网关会将 Token 写入 localStorage 并跳回业务前端业务前端无需处理登录请求只需从 localStorage 读取 Token</p>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>// main.ts 或 App.vue onMounted
const token = localStorage.getItem('token')
if (token) {
// 用户已登录,正常渲染应用
// 如需获取用户信息,调用网关 API
const res = await request.get('/zgapi/v1/auth/user/info')
console.log('当前用户:', res.data)
}</code></pre>
</div>
</section>
<!-- ====== 后端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">4</span>后端接入 (完整代码)</h2>
<h3 class="section-h3">4.1 网关转发到后端时注入的请求头</h3>
<el-table :data="headerTable" border stripe size="large" style="width:100%">
<el-table-column prop="name" label="请求头名称" width="200" />
<el-table-column prop="type" label="数据类型" width="120" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p class="text-p" style="margin-top:16px"><b>重要</b>这些请求头由网关注入业务后端可以信任这些值不需要再验证 Token 或查询用户信息</p>
<h3 class="section-h3">4.2 Java UserContext 工具类</h3>
<p class="text-p"> ThreadLocal 缓存请求头业务代码零侵入获取用户信息</p>
<div class="code-box">
<div class="code-label">Java</div>
<pre><code>// ====== UserContext.java ======
import java.util.*;
public class UserContext {
private static final ThreadLocal&lt;Map&lt;String, String&gt;&gt; CTX = new ThreadLocal&lt;&gt;();
public static void set(Map&lt;String, String&gt; user) { CTX.set(user); }
public static Map&lt;String, String&gt; get() { return CTX.get(); }
public static void clear() { CTX.remove(); }
// 快捷方法
public static String getUserId() { return get().get("X-User-Id"); }
public static String getUserName() { return get().get("X-User-Name"); }
public static String getUserAccount(){ return get().get("X-User-Account"); }
public static String getSystemCode() { return get().get("X-System-Code"); }
public static String getSystemId() { return get().get("X-System-Id"); }
}
// ====== UserContextFilter.java ======
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;
@Component
public class UserContextFilter implements Filter {
private static final String[] HEADERS = {
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Post", "X-User-Roles",
"X-System-Id", "X-System-Code", "X-System-Name"
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
Map&lt;String, String&gt; user = new HashMap&lt;&gt;();
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null) user.put(h, v);
}
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
UserContext.clear();
}
}
}
// ====== 业务代码中使用 ======
@RestController
public class OrderController {
@GetMapping("/api/orders")
public List&lt;Order&gt; list() {
String userId = UserContext.getUserId(); // "123"
String userName = UserContext.getUserName(); // "张三"
String systemCode = UserContext.getSystemCode(); // "msg"
return orderService.findByUser(userId, systemCode);
}
}</code></pre>
</div>
<h3 class="section-h3">4.3 Python Flask 中间件</h3>
<div class="code-box">
<div class="code-label">Python</div>
<pre><code># ====== middleware.py ======
from flask import request, g
USER_HEADERS = [
'X-User-Id', 'X-User-Name', 'X-User-Account',
'X-User-Email', 'X-User-Post', 'X-User-Roles',
'X-System-Id', 'X-System-Code', 'X-System-Name',
]
def user_context_middleware():
"""将网关注入的用户信息存到 Flask g 对象"""
for h in USER_HEADERS:
val = request.headers.get(h)
if val:
setattr(g, h.replace('-', '_').lower(), val)
# 注册到 app (Flask)
# app.before_request(user_context_middleware)
# ====== 业务代码中使用 ======
from flask import g
@app.route('/api/orders')
def list_orders():
user_id = g.x_user_id # "123"
user_name = g.x_user_name # "张三"
system_code = g.x_system_code # "msg"
return order_service.query(user_id, system_code)</code></pre>
</div>
<h3 class="section-h3">4.4 Go 中间件</h3>
<div class="code-box">
<div class="code-label">Go</div>
<pre><code>// ====== middleware.go ======
package middleware
import (
"context"
"net/http"
)
type contextKey string
func UserContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
for _, h := range []string{
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Roles",
"X-System-Id", "X-System-Code",
} {
if v := r.Header.Get(h); v != "" {
ctx = context.WithValue(ctx, contextKey(h), v)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 快捷函数
func GetUserID(ctx context.Context) string {
return ctx.Value(contextKey("X-User-Id")).(string)
}
func GetSystemCode(ctx context.Context) string {
return ctx.Value(contextKey("X-System-Code")).(string)
}
// ====== main.go: 注册中间件 ======
// http.Handle("/api/", middleware.UserContext(yourHandler))
// ====== 业务代码 ======
func listOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) // "123"
systemCode := middleware.GetSystemCode(r.Context()) // "msg"
// ...
}</code></pre>
</div>
</section>
<!-- ====== API 接口参考 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">5</span>API 接口参考</h2>
<h3 class="section-h3">5.1 获取当前用户信息</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/user/info</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"email": "admin@zgitm.com",
"phone": "13800000000",
"avatar": null,
"post": "管理员"
}
}</code></pre></div>
</div>
</div>
<h3 class="section-h3">5.2 获取系统菜单路由</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>树形菜单结构包含 path / name / component / children / meta( title, icon)
</div>
</div>
<h3 class="section-h3">5.3 退出登录</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/auth/logout</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
</div>
</section>
<!-- ====== 数据库初始化 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">6</span>数据库初始化</h2>
<p class="text-p">在网关平台的数据库执行以下 SQL{{ sys?.systemName }}创建默认菜单和角色</p>
<div class="code-box">
<div class="code-label">SQL</div>
<pre><code>{{ menuSql }}</code></pre>
</div>
</section>
<!-- ====== 测试清单 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">7</span>对接测试清单</h2>
<div class="checklist">
<div v-for="(item, i) in testChecklist" :key="i" class="check-item">
<el-icon :size="20" color="var(--color-primary)"><component :is="getIconComponent(item.done ? 'CircleCheckFilled' : 'CircleClose')" /></el-icon>
<span>{{ item.text }}</span>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
const route = useRoute()
const sys = ref<any>(null)
const token = route.params.token as string
const gatewayUrl = computed(() => import.meta.env.VITE_GATEWAY_URL || 'https://gw.server.zgitm.com')
const loginUrl = computed(() => import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com')
const headerTable = [
{ name: 'X-User-Id', type: 'String', desc: '当前登录用户的唯一ID' },
{ name: 'X-User-Name', type: 'String', desc: '用户真实姓名' },
{ name: 'X-User-Account', type: 'String', desc: '用户登录账号' },
{ name: 'X-User-Email', type: 'String', desc: '用户邮箱' },
{ name: 'X-User-Post', type: 'String', desc: '用户岗位' },
{ name: 'X-User-Roles', type: 'JSON', desc: '用户角色列表 [{roleId, roleName, roleCode}]' },
{ name: 'X-System-Id', type: 'String', desc: '当前业务系统的数据库ID' },
{ name: 'X-System-Code', type: 'String', desc: '当前业务系统的唯一编码' },
{ name: 'X-System-Name', type: 'String', desc: '当前业务系统的显示名称' },
]
const menuSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
const sn = sys.value?.systemName || '系统'
return `-- =============================================
-- ${sn} 菜单 & 角色初始化 SQL
-- 系统编码: ${sc}
-- 将 system_code 替换为你的编码后直接执行
-- =============================================
-- 1. 创建顶级菜单目录 (parent_id = 0)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 2. 创建子菜单 (parent_id = 对应父菜单ID)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '业务管理' AND m.system_id = s.id;
-- 3. 创建角色
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '管理员', 'admin', '拥有全部权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '普通用户', 'user', '拥有基本权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 4. 将菜单分配给角色
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id
FROM sys_role r, sys_menu m, sys_system s
WHERE r.role_code = 'admin'
AND r.system_id = s.id
AND m.system_id = s.id
AND s.system_code = '${sc}';`
})
const testChecklist = computed(() => [
{ text: '访问业务前端首页 → 自动跳转到统一登录页', done: false },
{ text: '使用测试账号登录 → 成功进入业务前端', done: false },
{ text: '调用 GET /zgapi/v1/auth/user/info → 返回用户信息', done: false },
{ text: '业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID', done: false },
{ text: '未放行的 API 请求被网关返回 403', done: false },
{ text: '退出登录后访问业务页面 → 重新跳转登录页', done: false },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/system/doc/' + token)
sys.value = res.data
} catch { /* ignore */ }
})
</script>
<style scoped>
/* ===== 整体 ===== */
.doc-page { min-height:100vh; background:var(--bg-page); padding:20px; }
.doc-container { max-width:960px; margin:0 auto; }
/* ===== 页头 ===== */
.doc-hero {
display:flex; align-items:center; justify-content:space-between;
padding:28px 32px; background:var(--bg-surface);
border-radius:var(--radius-xl); border:1px solid var(--border-color);
margin-bottom:24px; box-shadow:var(--shadow-sm);
}
.doc-hero-left { display:flex; align-items:center; gap:14px; }
.doc-logo { width:36px; height:36px; color:var(--color-primary); flex-shrink:0; }
.doc-hero-left h1 { font-size:22px; font-weight:700; margin:0 0 4px; color:var(--text-primary); }
.doc-hero-left p { font-size:14px; color:var(--text-muted); margin:0; }
/* ===== 章节 ===== */
.doc-section {
background:var(--bg-surface); border-radius:var(--radius-xl);
border:1px solid var(--border-color); padding:28px 32px;
margin-bottom:20px; box-shadow:var(--shadow-sm);
}
.section-h2 {
display:flex; align-items:center; gap:12px;
font-size:20px; font-weight:700; color:var(--text-primary);
margin:0 0 20px; padding-bottom:16px;
border-bottom:1px solid var(--border-light);
}
.s-num {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:var(--radius-md);
background:var(--color-primary); color:#fff;
font-size:15px; font-weight:700; flex-shrink:0;
}
.section-h3 {
font-size:16px; font-weight:600; color:var(--text-primary);
margin:24px 0 10px;
}
.section-h3:first-of-type { margin-top:0; }
/* ===== 文字 ===== */
.text-p { font-size:14px; color:var(--text-secondary); line-height:1.7; margin:0 0 12px; }
.inline-code {
background:var(--color-primary-bg); color:var(--color-primary);
padding:2px 8px; border-radius:4px; font-size:13px;
font-family:'SF Mono','Consolas',monospace;
}
/* ===== 架构图 ===== */
.arch-text {
background:#1e293b; color:#e2e8f0; padding:16px 20px;
border-radius:var(--radius-md); font-size:13px;
line-height:1.7; overflow-x:auto; white-space:pre;
font-family:'SF Mono','Consolas',monospace;
}
.flow-steps { margin-top:16px; display:flex; flex-direction:column; gap:10px; }
.flow-step { font-size:14px; color:var(--text-secondary); display:flex; align-items:flex-start; gap:8px; line-height:1.6; }
.flow-step b { color:var(--color-primary); flex-shrink:0; }
/* ===== 代码块 ===== */
.code-box {
position:relative; background:#1e293b; border-radius:var(--radius-md);
margin-bottom:16px; overflow:hidden;
}
.code-label {
position:absolute; top:8px; right:12px; z-index:1;
font-size:11px; color:rgba(255,255,255,0.45); font-weight:600;
letter-spacing:0.5px; text-transform:uppercase;
}
.code-box pre {
margin:0; padding:20px; overflow-x:auto;
}
.code-box code {
font-family:'SF Mono','Fira Code','Consolas',monospace;
font-size:13px; line-height:1.75; color:#e2e8f0; white-space:pre;
}
/* ===== API 卡片 ===== */
.api-card {
border:1px solid var(--border-color); border-radius:var(--radius-lg);
padding:20px; margin-bottom:16px;
}
.api-method {
display:inline-block; padding:2px 10px; border-radius:4px;
font-size:12px; font-weight:700; color:#fff; margin-bottom:8px;
}
.api-method.get { background:#10b981; }
.api-method.post { background:#6366f1; }
.api-method.put { background:#f59e0b; }
.api-path {
font-size:15px; font-weight:600; color:var(--text-primary);
font-family:'SF Mono','Consolas',monospace; margin-bottom:8px;
}
.api-headers { font-size:14px; color:var(--text-secondary); margin-bottom:8px; }
.api-response { font-size:14px; color:var(--text-secondary); }
/* ===== 测试清单 ===== */
.checklist { display:flex; flex-direction:column; gap:14px; }
.check-item { display:flex; align-items:center; gap:10px; font-size:14px; color:var(--text-secondary); }
/* 架构警告 */
.arch-warnings { margin-top:16px; display:flex; flex-direction:column; gap:12px; }
.warn-item {
background:#fef2f2; border:1px solid #fecaca; border-radius:var(--radius-md);
padding:14px 18px;
}
.warn-item b { color:#dc2626; font-size:14px; display:block; margin-bottom:4px; }
.warn-item p { color:var(--text-secondary); font-size:14px; margin:0 0 8px; line-height:1.7; }
@media (max-width:768px) {
.doc-hero { flex-direction:column; gap:12px; align-items:flex-start; }
.doc-section { padding:20px; }
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- 左侧品牌区 -->
<div class="login-brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<h1 class="brand-title">Mokee Gateway</h1>
<p class="brand-desc">统一登录网关管理平台</p>
<div class="brand-features">
<div class="feature-item">
<span class="feature-dot" />
<span>统一身份认证</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>智能网关转发</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>细粒度权限管控</span>
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-form-panel">
<div class="form-header">
<h2>欢迎回来</h2>
<p>请输入账号密码登录系统</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="submit-btn"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<footer class="login-footer">
&copy; 2026 Mokee Gateway
</footer>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
// PROD: 登录后切到主应用域名,避免从 login 域名加载 JS
const webUrl = import.meta.env.VITE_WEB_URL
if (webUrl && webUrl !== window.location.origin) {
window.location.href = webUrl + '/select'
} else {
router.push('/select')
}
} catch (e: any) {
// 响应拦截器已展示通用错误(如 401/403/429
// 此处处理拦截器未覆盖的业务异常信息
const msg = e?.response?.data?.msg || e?.message
if (msg && msg !== '请求失败') ElMessage.error(msg)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f8fafc;
padding: 24px;
}
.login-container {
display: flex;
width: 880px;
max-width: 100%;
min-height: 480px;
background: #fff;
border-radius: 20px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 20px 60px -20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* ===== 左侧品牌区 ===== */
.login-brand {
flex: 1;
background: linear-gradient(160deg, #4f46e5 0%, #6366f1 40%, #818cf8 100%);
padding: 48px 40px;
display: flex;
flex-direction: column;
justify-content: center;
color: #fff;
}
.brand-icon {
width: 52px;
height: 52px;
background: rgba(255, 255, 255, 0.18);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
backdrop-filter: blur(10px);
}
.brand-icon svg {
width: 28px;
height: 28px;
}
.brand-title {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.brand-desc {
font-size: 14px;
opacity: 0.8;
font-weight: 400;
margin-bottom: 40px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 14px;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
opacity: 0.85;
}
.feature-dot {
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
flex-shrink: 0;
}
/* ===== 右侧登录表单 ===== */
.login-form-panel {
flex: 1;
padding: 48px 44px;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-header {
margin-bottom: 32px;
}
.form-header h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.form-header p {
font-size: 14px;
color: var(--text-muted);
}
.submit-btn {
width: 100%;
height: 46px;
font-size: 15px;
letter-spacing: 3px;
border-radius: var(--radius-lg);
margin-top: 4px;
}
.login-footer {
margin-top: 28px;
color: var(--text-muted);
font-size: 12px;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.login-page { padding: 0; justify-content: flex-start; }
.login-container {
flex-direction: column;
min-height: 100vh;
border-radius: 0;
box-shadow: none;
width: 100%;
}
.login-brand {
padding: 40px 24px 24px;
text-align: center;
align-items: center;
}
.brand-icon { width: 44px; height: 44px; margin-bottom: 16px; }
.brand-icon svg { width: 22px; height: 22px; }
.brand-title { font-size: 22px; }
.brand-features { display: none; }
.brand-desc { margin-bottom: 0; }
.login-form-panel { padding: 24px 24px 40px; }
.form-header h2 { font-size: 20px; }
.login-footer { display: none; }
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<div class="select-page">
<!-- 顶栏 -->
<header class="select-topbar">
<div class="topbar-brand">
<svg class="brand-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="brand-text">Mokee Gateway</span>
</div>
<div class="topbar-actions">
<el-button v-if="authStore.isAdmin" type="primary" size="default" @click="router.push('/admin')">
<el-icon><Setting /></el-icon>后台管理
</el-button>
<el-button text @click="handleLogout" style="color: var(--text-muted)">
<el-icon><SwitchButton /></el-icon>退出
</el-button>
</div>
</header>
<!-- 内容 -->
<div class="select-body">
<div class="select-hero">
<el-avatar :size="56" :src="authStore.userInfo?.avatar" class="hero-avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<h2 class="hero-greeting">
你好{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}
</h2>
<p class="hero-sub">请选择要访问的业务系统</p>
</div>
<div class="system-grid" v-if="authStore.systemList.length > 0">
<div
v-for="(sys, i) in authStore.systemList"
:key="sys.systemId"
class="system-card"
@click="handleSelectSystem(sys)"
>
<div class="card-icon-box" :style="{ background: isIconUrl(sys.icon) ? 'transparent' : gradients[i % gradients.length] }">
<img v-if="isIconUrl(sys.icon)" :src="sys.icon" class="card-icon-img" />
<el-icon v-else :size="28"><component :is="getIcon(sys.icon)" /></el-icon>
</div>
<div class="card-info">
<div class="card-title">{{ sys.systemName }}</div>
<div class="card-code">{{ sys.systemCode }}</div>
<div class="card-desc">{{ sys.description || '暂无描述' }}</div>
</div>
<el-icon class="card-arrow" :size="16"><ArrowRight /></el-icon>
</div>
</div>
<el-empty
v-else
description="暂无可用系统,请联系管理员配置"
:image-size="140"
>
<el-button type="primary" @click="authStore.fetchSystemList()">刷新</el-button>
</el-empty>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { SwitchButton, Grid, Setting, ArrowRight } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import { useAuthStore, type SystemInfo } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const gradients = [
'linear-gradient(135deg, #6366f1, #818cf8)',
'linear-gradient(135deg, #10b981, #34d399)',
'linear-gradient(135deg, #f59e0b, #fbbf24)',
'linear-gradient(135deg, #ef4444, #f87171)',
'linear-gradient(135deg, #8b5cf6, #a78bfa)',
'linear-gradient(135deg, #06b6d4, #22d3ee)',
'linear-gradient(135deg, #ec4899, #f472b6)',
'linear-gradient(135deg, #3b82f6, #60a5fa)',
]
onMounted(async () => {
await authStore.fetchUserInfo()
await authStore.fetchSystemList()
})
function getIcon(icon: string) {
return getIconComponent(icon) || Grid
}
/** 图标值是 URL上传的文件图标还是 Element Plus 组件名 */
function isIconUrl(v: string) {
return v && (v.startsWith('http') || v.startsWith('/zgapi'))
}
async function handleSelectSystem(sys: SystemInfo) {
try {
await authStore.selectSystem(sys.systemId)
if (sys.systemCode === 'gateway') {
// 跳转到主应用域名,避免从 login 域名加载大 JS
const webUrl = import.meta.env.VITE_WEB_URL || window.location.origin
window.location.href = webUrl + '/admin'
return
}
if (sys.frontUrl) {
window.location.href = sys.frontUrl
} else {
ElMessage.warning('该系统未配置前端地址')
}
} catch {
ElMessage.error('切换系统失败')
}
}
async function handleLogout() {
try { await authStore.logout() } catch {}
router.push('/login')
}
</script>
<style scoped>
.select-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* ===== 顶栏 ===== */
.select-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: #fff;
border-bottom: 1px solid var(--border-color);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-svg {
width: 26px;
height: 26px;
color: var(--color-primary);
}
.brand-text {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== 内容 ===== */
.select-body {
flex: 1;
padding: 48px 40px 60px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.select-hero {
text-align: center;
margin-bottom: 44px;
}
.hero-avatar {
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2);
}
.hero-greeting {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.hero-sub {
font-size: 15px;
color: var(--text-muted);
}
/* ===== 系统卡片网格 ===== */
.system-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.system-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-xs);
}
.system-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
border-color: #cbd5e1;
}
.card-icon-box {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.card-icon-img { width:40px; height:40px; object-fit:contain; }
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-code {
font-size: 12px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
margin-bottom: 4px;
}
.card-desc {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-arrow {
color: var(--text-muted);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.system-card:hover .card-arrow {
color: var(--color-primary);
transform: translateX(3px);
}
@media (max-width: 768px) {
.select-body { padding: 24px 16px 40px; }
.select-hero { margin-bottom: 28px; }
.hero-greeting { font-size: 20px; }
.system-grid { grid-template-columns: 1fr; gap: 12px; }
.select-topbar { padding: 0 12px; }
}
</style>

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5001,
},
build: {
target: 'es2015',
cssTarget: 'chrome61',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/element-plus')) return 'el'
if (id.includes('node_modules/echarts')) return 'echarts'
if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) return 'vue-vendor'
},
},
},
},
})

61
mokee-gateway-web/components.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mokee UserChip Widget — 接入演示</title>
<!--
外部系统接入只需这三步:
1. 引入 Widget 样式
2. 放置占位元素 <div id="mokee-user-chip">
3. 引入 Widget 脚本
-->
<!-- 第 1 步:引入 Widget 样式 -->
<link rel="stylesheet" href="/widgets/user-chip.css" />
<style>
/* 演示页面自己的样式(不属于 Widget */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.demo-header-left {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.demo-header-right {
display: flex;
align-items: center;
}
.demo-body {
max-width: 800px;
margin: 40px auto;
padding: 32px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.demo-body h2 { margin-bottom: 12px; color: #1f2937; font-size: 20px; }
.demo-body p { color: #6b7280; line-height: 1.8; margin-bottom: 8px; }
.demo-code {
margin-top: 16px; padding: 16px 20px;
background: #1e293b; color: #e2e8f0;
border-radius: 8px; font-family: 'Consolas', 'SF Mono', monospace;
font-size: 13px; line-height: 1.8; overflow-x: auto;
}
.demo-code .hl { color: #6366f1; }
.demo-code .cm { color: #6b7280; }
</style>
</head>
<body>
<!--
模拟外部系统的顶部导航栏
右上角放 Mokee UserChip Widget
-->
<header class="demo-header">
<div class="demo-header-left">某业务系统</div>
<div class="demo-header-right">
<!--
===== 第 2 步:占位元素 =====
data-api-base 指向网关 API 地址
loginUrl 和 selectUrl 无需配置,自动从脚本 URL 推断
-->
<div
id="mokee-user-chip"
data-api-base="http://127.0.0.1:9000"
></div>
</div>
</header>
<div class="demo-body">
<h2>欢迎使用某业务系统</h2>
<p>
右上角的用户头像下拉组件由 <b>Mokee Gateway</b> 提供。
外部系统只需三行代码即可接入,无需手写登录/鉴权/用户信息获取逻辑。
</p>
<p>
组件功能:未登录显示"登录"按钮;已登录显示用户头像 + 用户名,
下拉菜单包含"切换系统"和"退出登录"。
</p>
<div class="demo-code">
<span class="cm">&lt;!-- 1. 引入样式 --&gt;</span><br/>
&lt;link rel=<span class="hl">"stylesheet"</span> href=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.css"</span> /&gt;<br/><br/>
<span class="cm">&lt;!-- 2. 占位元素(只需配 API 网关地址) --&gt;</span><br/>
&lt;div id=<span class="hl">"mokee-user-chip"</span><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data-api-base=<span class="hl">"https://gw.server.zgitm.com"</span>&gt;&lt;/div&gt;<br/><br/>
<span class="cm">&lt;!-- 3. 引入脚本自动渲染login/select 地址自动推断) --&gt;</span><br/>
&lt;script src=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.js"</span>&gt;&lt;/script&gt;
</div>
</div>
<!-- 第 3 步:引入 Widget 脚本(放 body 底部,确保 DOM 就绪) -->
<script src="/widgets/user-chip.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
.mokee-uc-root[data-v-26ebf7bf]{--mokee-uc-text-primary: #1f2937;--mokee-uc-text-secondary: #6b7280;--mokee-uc-text-muted: #9ca3af;--mokee-uc-bg-hover: #f3f4f6;--mokee-uc-border-color: #e5e7eb;--mokee-uc-color-primary: #6366f1;--mokee-uc-radius-sm: 4px;--mokee-uc-radius-full: 24px;--mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;--mokee-uc-transition: .15s ease;display:inline-flex;align-items:center;font-family:var(--mokee-uc-font-family);font-size:14px;line-height:1.5;color:var(--mokee-uc-text-primary);-webkit-font-smoothing:antialiased}.mokee-uc-chip[data-v-26ebf7bf]{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 5px;border-radius:var(--mokee-uc-radius-full);cursor:pointer;border:1px solid transparent;transition:all var(--mokee-uc-transition);user-select:none;white-space:nowrap}.mokee-uc-chip[data-v-26ebf7bf]:hover{background:var(--mokee-uc-bg-hover);border-color:var(--mokee-uc-border-color)}.mokee-uc-avatar[data-v-26ebf7bf]{flex-shrink:0}.mokee-uc-name[data-v-26ebf7bf]{font-size:14px;font-weight:500;color:var(--mokee-uc-text-secondary);max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mokee-uc-chevron[data-v-26ebf7bf]{color:var(--mokee-uc-text-muted);flex-shrink:0}.mokee-uc-chip--loading[data-v-26ebf7bf]{cursor:default;gap:10px;padding:5px 12px}.mokee-uc-chip--loading[data-v-26ebf7bf]:hover{background:transparent;border-color:transparent}.mokee-uc-skeleton-avatar[data-v-26ebf7bf]{width:28px;height:28px;border-radius:50%;background:var(--mokee-uc-border-color);animation:mokee-uc-pulse-26ebf7bf 1.5s ease-in-out infinite}.mokee-uc-skeleton-name[data-v-26ebf7bf]{width:50px;height:14px;border-radius:var(--mokee-uc-radius-sm);background:var(--mokee-uc-border-color);animation:mokee-uc-pulse-26ebf7bf 1.5s ease-in-out infinite}@keyframes mokee-uc-pulse-26ebf7bf{0%,to{opacity:1}50%{opacity:.4}}.mokee-uc-chip--guest[data-v-26ebf7bf]{padding:5px 14px;color:var(--mokee-uc-color-primary);font-weight:500}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>统一登录网关平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
---
sessionId: 20260612-1
startTime: 2026-06-12T10:00:00+08:00
status: completed
---
# 2026-06-12 会话Vue/TypeScript 文件中文注释补全
## 概要
为 mokee-gateway-web 项目的 19 个核心文件添加了详细的中文注释,包括文件头说明、逻辑块分隔、函数 JSDoc、API 调用说明、数据流说明。
## 已完成文件
### 核心配置4 个)
1. **vite.config.ts** — unplugin-vue-components 按需加载原理、manualChunks 分包策略、modulepreload 剥离插件机制
2. **src/main.ts** — 最小化启动引导流程、Pinia→Router 注册顺序、v-permission 指令说明
3. **src/App.vue** — ElConfigProvider 中文语言包全局配置
4. **src/utils/icons.ts** — 动态图标注册表原理解决按需导入后字符串名找不到组件、getIconComponent 使用场景
### 仪表盘1 个)
5. **src/views/admin/Dashboard.vue** — 4 张统计卡片、ECharts 动态加载、JVM 信息面板、图表 renderChart 函数详解
### 日志页2 个)
6. **src/views/admin/log/LoginLogList.vue** — systemName 列说明、登录状态 1/0 含义、日期参数拆分逻辑
7. **src/views/admin/log/OperationLogList.vue** — action 枚举值映射、HTTP method 颜色映射、JSON 格式化安全处理
### 列表视图6 个)
8. **src/views/admin/system/SystemList.vue** — 图标列动态渲染、v-permission 权限控制、docToken 文档跳转
9. **src/views/admin/user/UserList.vue** — 用户分页、编辑模式路由跳转
10. **src/views/admin/role/RoleList.vue** — 分配菜单完整流程并行加载、半选节点处理、systemName 连表查询
11. **src/views/admin/menu/MenuList.vue** — 树形表格、展开/折叠递归算法、菜单表单弹窗逻辑、PUT URL 修正说明
12. **src/views/admin/dict/DictList.vue** — 前端 dictLabel 过滤、不分页设计、新增字典类型弹窗
13. **src/views/admin/system/ApiList.vue** — localStorage.currentSystemId 默认系统选中、method 颜色映射
### 表单页4 个)
14. **src/views/admin/system/ApiForm.vue** — query.systemId 预设、apiDesc 填充修复说明
15. **src/views/admin/user/UserForm.vue** — 系统绑定/角色分配完整流程、系统列表端点修正说明
16. **src/views/admin/role/RoleForm.vue** — 系统列表端点统一说明
17. **src/views/admin/menu/MenuForm.vue** — 动态校验规则(按菜单类型)、菜单树加载顺序
### 选择/文档页2 个)
18. **src/views/select/SystemSelectView.vue** — getIconComponent 降级逻辑、gateway 跨域名跳转原理、渐变配色方案
19. **src/views/docs/IntegrationDoc.vue** — 8 章节内容概述、menuSql/adminSql 生成逻辑、BCrypt 密码说明
## 注释规范
- 文件头:`<!-- -->``/** */` 格式,说明页面/组件用途
- 逻辑块:`// ===== 章节名 =====` 格式分隔
- 函数:`/** */` JSDoc 格式,含 @param/@returns
- 内联:`//` 用于 API 调用、数据转换、非显而易见逻辑
- 所有注释使用简体中文,代码逻辑未改动

2754
mokee-gateway-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "mokee-gateway-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5001",
"build": "vue-tsc && vite build",
"build:widget": "vite build --config vite.widget.config.ts",
"build:prod": "vite build && vite build --config vite.widget.config.ts",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"echarts": "^6.1.0",
"element-plus": "^2.8.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.4.0",
"unplugin-auto-import": "^0.18.0",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0"
}
}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mokee UserChip Widget — 接入演示</title>
<!--
外部系统接入只需这三步:
1. 引入 Widget 样式
2. 放置占位元素 <div id="mokee-user-chip">
3. 引入 Widget 脚本
-->
<!-- 第 1 步:引入 Widget 样式 -->
<link rel="stylesheet" href="/widgets/user-chip.css" />
<style>
/* 演示页面自己的样式(不属于 Widget */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.demo-header-left {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.demo-header-right {
display: flex;
align-items: center;
}
.demo-body {
max-width: 800px;
margin: 40px auto;
padding: 32px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.demo-body h2 { margin-bottom: 12px; color: #1f2937; font-size: 20px; }
.demo-body p { color: #6b7280; line-height: 1.8; margin-bottom: 8px; }
.demo-code {
margin-top: 16px; padding: 16px 20px;
background: #1e293b; color: #e2e8f0;
border-radius: 8px; font-family: 'Consolas', 'SF Mono', monospace;
font-size: 13px; line-height: 1.8; overflow-x: auto;
}
.demo-code .hl { color: #6366f1; }
.demo-code .cm { color: #6b7280; }
</style>
</head>
<body>
<!--
模拟外部系统的顶部导航栏
右上角放 Mokee UserChip Widget
-->
<header class="demo-header">
<div class="demo-header-left">某业务系统</div>
<div class="demo-header-right">
<!--
===== 第 2 步:占位元素 =====
data-api-base 指向网关 API 地址
loginUrl 和 selectUrl 无需配置,自动从脚本 URL 推断
-->
<div
id="mokee-user-chip"
data-api-base="http://127.0.0.1:9000"
></div>
</div>
</header>
<div class="demo-body">
<h2>欢迎使用某业务系统</h2>
<p>
右上角的用户头像下拉组件由 <b>Mokee Gateway</b> 提供。
外部系统只需三行代码即可接入,无需手写登录/鉴权/用户信息获取逻辑。
</p>
<p>
组件功能:未登录显示"登录"按钮;已登录显示用户头像 + 用户名,
下拉菜单包含"切换系统"和"退出登录"。
</p>
<div class="demo-code">
<span class="cm">&lt;!-- 1. 引入样式 --&gt;</span><br/>
&lt;link rel=<span class="hl">"stylesheet"</span> href=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.css"</span> /&gt;<br/><br/>
<span class="cm">&lt;!-- 2. 占位元素(只需配 API 网关地址) --&gt;</span><br/>
&lt;div id=<span class="hl">"mokee-user-chip"</span><br/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;data-api-base=<span class="hl">"https://gw.server.zgitm.com"</span>&gt;&lt;/div&gt;<br/><br/>
<span class="cm">&lt;!-- 3. 引入脚本自动渲染login/select 地址自动推断) --&gt;</span><br/>
&lt;script src=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.js"</span>&gt;&lt;/script&gt;
</div>
</div>
<!-- 第 3 步:引入 Widget 脚本(放 body 底部,确保 DOM 就绪) -->
<script src="/widgets/user-chip.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!--
根组件 全局配置提供者
职责
- 使用 ElConfigProvider 设置全局中文语言包
- 包裹 <router-view /> 使所有子组件使用 Element Plus 中文本地化
- 不包含任何布局布局由各路由页面自行处理登录/选系统/管理后台布局不同
-->
<template>
<!-- Element Plus 全局配置统一设置为中文语言包 -->
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
// Element Plus 中文语言包 — 使所有组件的默认文案(分页、日历等)显示为中文
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>

View File

@@ -0,0 +1,171 @@
<template>
<div class="icon-picker">
<div class="icon-trigger" @click="visible = true">
<template v-if="modelValue">
<img v-if="isUrl(modelValue)" :src="modelValue" class="trigger-img" />
<el-icon v-else :size="20"><component :is="Icons[modelValue]" /></el-icon>
</template>
<span v-else class="placeholder">选择图标</span>
<el-icon class="arrow"><ArrowDown /></el-icon>
</div>
<Teleport to="body">
<div v-if="visible" class="icon-overlay" @click="visible = false">
<div class="icon-popup" @click.stop>
<div class="popup-header">
<span>选择图标</span>
<el-radio-group v-model="tab" size="small">
<el-radio-button value="builtin">内置图标</el-radio-button>
<el-radio-button value="upload">已上传</el-radio-button>
</el-radio-group>
<el-input v-model="search" placeholder="搜索..." size="small" clearable style="width:200px" />
<span class="count">{{ filtered.length }} </span>
<el-icon class="close-btn" :size="20" @click="visible = false"><Close /></el-icon>
</div>
<!-- 内置图标 -->
<div class="icon-grid" v-if="tab === 'builtin' && filtered.length > 0">
<div
v-for="ico in filtered" :key="ico"
:class="['icon-cell', { active: modelValue === ico }]"
@click="select(ico)"
:title="ico"
>
<el-icon :size="36"><component :is="Icons[ico]" /></el-icon>
<span class="icon-name">{{ ico }}</span>
</div>
</div>
<!-- 已上传图标 -->
<div class="icon-grid uploaded-grid" v-if="tab === 'upload' && filteredUploads.length > 0">
<div
v-for="item in filteredUploads" :key="item.id"
:class="['icon-cell', { active: modelValue === getFileUrl(item.id) }]"
@click="select(getFileUrl(item.id))"
:title="item.fileName"
>
<img :src="getFileUrl(item.id)" class="upload-icon-thumb" />
<span class="icon-name">{{ item.fileName }}</span>
</div>
</div>
<el-empty v-if="(tab === 'builtin' && filtered.length === 0) || (tab === 'upload' && filteredUploads.length === 0)"
description="无匹配图标" :image-size="60" />
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ArrowDown, Close } from '@element-plus/icons-vue'
import * as Icons from '@element-plus/icons-vue'
import request from '@/utils/request'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const visible = ref(false)
const search = ref('')
const tab = ref('builtin')
const uploadedIcons = ref<{id:string, fileName:string}[]>([])
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
function isUrl(v: string) { return v && (v.startsWith('http') || v.startsWith('/zgapi')) }
function getFileUrl(id: string) { return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view` }
// 内置图标
const builtinIcons = Object.keys(Icons).filter(k =>
k !== 'default' && !k.startsWith('_') && k !== 'Menu'
)
const filtered = computed(() => {
const s = search.value.toLowerCase()
return s ? builtinIcons.filter(i => i.toLowerCase().includes(s)) : builtinIcons
})
// 已上传图标
const filteredUploads = computed(() => {
const s = search.value.toLowerCase()
return s
? uploadedIcons.value.filter(i => i.fileName.toLowerCase().includes(s))
: uploadedIcons.value
})
async function fetchUploadedIcons() {
try {
const res = await request.get('/zgapi/v1/admin/file/list', { params: { fileType: 'icon' } })
uploadedIcons.value = res.data || []
} catch {}
}
function select(val: string) {
emit('update:modelValue', val)
visible.value = false
}
onMounted(fetchUploadedIcons)
</script>
<style scoped>
.icon-picker { width: 100%; }
.icon-trigger {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--bg-surface); border: 1px solid var(--border-color);
border-radius: var(--radius-md); cursor: pointer; transition: all var(--transition-fast);
}
.icon-trigger:hover { border-color: var(--color-primary); }
.placeholder { font-size: 13px; color: var(--text-muted); flex: 1; }
.arrow { color: var(--text-muted); flex-shrink: 0; }
.icon-overlay {
position: fixed; inset: 0; z-index: 9000;
background: rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center;
}
.icon-popup {
width: 920px; max-height: 640px;
background: var(--bg-surface); border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl); overflow: hidden;
display: flex; flex-direction: column;
}
.popup-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 15px; flex-shrink: 0;
}
.popup-header span { flex: 1; }
.count { font-size: 12px; color: var(--text-muted); font-weight: 400; }
.close-btn { cursor: pointer; color: var(--text-muted); flex-shrink: 0; }
.close-btn:hover { color: var(--text-primary); }
.icon-grid {
flex: 1; overflow-y: auto; padding: 20px;
display: grid; grid-template-columns: repeat(10, 1fr); gap: 10px;
}
.icon-cell {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 14px 6px; border-radius: var(--radius-md); cursor: pointer;
transition: all var(--transition-fast); border: 2px solid transparent;
}
.icon-cell:hover { background: var(--bg-hover); border-color: var(--border-color); }
.icon-cell.active { background: var(--color-primary-bg); border-color: var(--color-primary); color: var(--color-primary); }
.icon-name { font-size: 10px; color: var(--text-muted); text-align: center; word-break: break-all; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.icon-cell.active .icon-name { color: var(--color-primary); }
/* 触发器中的图片预览 */
.trigger-img { width:20px; height:20px; object-fit:contain; border-radius:3px; }
/* 已上传图标 */
.uploaded-grid { grid-template-columns: repeat(8, 1fr); }
.upload-icon-thumb { width:48px; height:48px; object-fit:contain; border-radius:var(--radius-sm); }
@media (max-width:960px) {
.icon-popup { width:98vw; max-height:90vh; }
.icon-grid { grid-template-columns: repeat(6, 1fr); }
.uploaded-grid { grid-template-columns: repeat(4, 1fr); }
}
</style>

View File

@@ -0,0 +1,140 @@
<!--
右下角浮动上传面板 全局常驻显示上传队列
刷新页面后任务状态保留localStorage但上传连接会中断标记为"已中断"
-->
<template>
<Teleport to="body">
<!-- 浮动按钮 -->
<div class="upload-fab" @click="uploadStore.panelVisible = !uploadStore.panelVisible">
<el-badge :value="uploadStore.activeCount || uploadStore.failedCount" :hidden="!uploadStore.activeCount && !uploadStore.failedCount" :type="uploadStore.failedCount ? 'danger' : 'primary'">
<el-icon :size="22"><UploadFilled /></el-icon>
</el-badge>
</div>
<!-- 展开面板 -->
<transition name="slide-up">
<div v-if="uploadStore.panelVisible" class="upload-panel">
<div class="panel-header">
<span class="panel-title">
上传队列
<span v-if="uploadStore.activeCount" class="panel-badge">{{ uploadStore.activeCount }} 个进行中</span>
</span>
<div class="panel-actions">
<el-button size="small" text @click="uploadStore.clearCompleted" v-if="uploadStore.tasks.length > 0">清除已完成</el-button>
<el-button size="small" text @click="uploadStore.panelVisible = false"><el-icon><Close /></el-icon></el-button>
</div>
</div>
<div class="panel-body" v-if="uploadStore.tasks.length > 0">
<div
v-for="t in uploadStore.tasks"
:key="t.id"
class="upload-item"
:class="t.status"
>
<div class="item-info">
<span class="item-name">{{ t.fileName }}</span>
<span class="item-size">{{ formatSize(t.fileSize) }}</span>
<span class="item-type">{{ t.fileType }}</span>
</div>
<!-- 进度条 -->
<el-progress
v-if="t.status === 'uploading'"
:percentage="t.progress"
:stroke-width="10"
:show-text="true"
/>
<div class="item-speed" v-if="t.status === 'uploading' && t.speed">{{ t.speed }}</div>
<!-- 状态标签 -->
<div class="item-status">
<el-tag v-if="t.status === 'completed'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="t.status === 'failed'" type="danger" size="small">{{ t.error || '失败' }}</el-tag>
<el-tag v-else-if="t.status === 'interrupted'" type="warning" size="small">已中断(刷新页面)</el-tag>
<el-tag v-else-if="t.status === 'queued'" type="info" size="small">排队中</el-tag>
</div>
</div>
</div>
<el-empty v-else description="暂无上传任务" :image-size="60" />
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { UploadFilled, Close } from '@element-plus/icons-vue'
import { useUploadStore } from '@/stores/upload'
const uploadStore = useUploadStore()
function formatSize(bytes: number) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
</script>
<style scoped>
/* 浮动按钮 */
.upload-fab {
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
width: 52px; height: 52px; border-radius: 50%;
background: var(--color-primary); color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 20px rgba(99,102,241,0.4);
transition: all 0.2s;
}
.upload-fab:hover { transform: scale(1.08); box-shadow: 0 6px 28px rgba(99,102,241,0.5); }
/* 展开面板 */
.upload-panel {
position: fixed; bottom: 88px; right: 24px; z-index: 2000;
width: 420px; max-height: 480px;
background: var(--bg-surface); border-radius: var(--radius-xl);
box-shadow: 0 8px 40px rgba(0,0,0,0.15);
border: 1px solid var(--border-color);
display: flex; flex-direction: column; overflow: hidden;
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border-light);
}
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.panel-badge { font-size: 12px; color: var(--color-primary); margin-left: 8px; font-weight: 400; }
.panel-actions { display: flex; gap: 4px; }
.panel-body { flex: 1; overflow-y: auto; padding: 12px; }
/* 上传项 */
.upload-item {
padding: 10px 12px; border-radius: var(--radius-md);
background: var(--bg-page); margin-bottom: 8px;
border: 1px solid var(--border-light);
}
.upload-item.completed { border-color: #bbf7d0; }
.upload-item.failed, .upload-item.interrupted { border-color: #fecaca; }
.item-info { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.item-name { font-size: 13px; font-weight: 500; color: var(--text-primary); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.item-size { font-size: 11px; color: var(--text-muted); flex-shrink:0; }
.item-type { font-size: 11px; color: var(--text-muted); flex-shrink:0; }
.item-speed { font-size: 11px; color: var(--text-muted); text-align: right; margin-top: 2px; }
.item-status { margin-top: 4px; }
/* 动画 */
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.25s ease; }
.slide-up-enter-from, .slide-up-leave-to { opacity: 0; transform: translateY(20px); }
/* 移动端 */
@media (max-width:768px) {
.upload-panel { width: calc(100vw - 24px); right: 12px; bottom: 80px; }
.upload-fab { bottom: 16px; right: 16px; }
}
</style>

View File

@@ -0,0 +1,18 @@
/**
* 响应式检测是否为手机屏幕(宽度 <= 768px
* 用于桌面表格 / 手机卡片的条件渲染
*/
import { ref, onMounted, onUnmounted } from 'vue'
export function useIsMobile() {
const isMobile = ref(window.innerWidth <= 768)
function check() {
isMobile.value = window.innerWidth <= 768
}
onMounted(() => window.addEventListener('resize', check))
onUnmounted(() => window.removeEventListener('resize', check))
return isMobile
}

View File

@@ -0,0 +1,20 @@
import type { Directive } from 'vue'
import { useAuthStore } from '@/stores/auth'
/**
* v-permission 自定义指令
* 用法: <el-button v-permission="'system:user:add'">新增用户</el-button>
* 如果用户没有该权限,元素会被隐藏
*/
export const permission: Directive = {
mounted(el, binding) {
const auth = useAuthStore()
const perm = binding.value as string
if (!perm) return
if (!auth.hasPermission(perm)) {
el.parentNode?.removeChild(el)
}
},
}
export default permission

7
mokee-gateway-web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,43 @@
/**
* 应用入口 — 最小化启动引导
*
* 启动流程:
* 1. 创建 Vue 应用实例
* 2. 注册 Pinia 状态管理(在 Router 之前,因为路由守卫可能用到 store
* 3. 注册 Vue Router路由守卫在此处理登录态和权限
* 4. 注册 v-permission 自定义指令(用于按钮级权限控制)
* 5. 挂载到 #app
*
* 注意:不在此处加载 Element Plus 全局样式 — 由 vite.config.ts 中的 unplugin-vue-components 按需导入
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// 全局 CSS 变量和基础样式
import './styles/global.css'
// 显式引入命令式组件 CSSElMessage/ElMessageBox/ElNotification 通过 JS 调用时
// unplugin-vue-components 无法检测,需手动导入,否则弹窗无样式显示在页面底部)
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-message-box.css'
import 'element-plus/theme-chalk/el-notification.css'
// v-permission 自定义指令 — 根据用户权限控制元素显隐
import { permission } from './directives/permission'
const app = createApp(App)
// Pinia 必须先于 Router 注册,因为路由守卫可能访问 user/permission store
app.use(createPinia())
app.use(router)
// 注册 v-permission 指令:<el-button v-permission="'system:user:add'">新增</el-button>
app.directive('permission', permission)
app.mount('#app')
// 手机端分页切换后自动滚回顶部(避免看到上一页的底部位置)
document.addEventListener('click', (e) => {
const el = e.target as HTMLElement
if (el.closest('.el-pager') || el.closest('.el-pagination__next') || el.closest('.el-pagination__prev')) {
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 250)
}
})

View File

@@ -0,0 +1,208 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '统一登录' },
},
{
path: '/select',
name: 'SystemSelect',
component: () => import('@/views/select/SystemSelectView.vue'),
meta: { title: '选择系统', requiresAuth: true },
},
{
path: '/admin',
name: 'AdminLayout',
component: () => import('@/views/admin/AdminLayout.vue'),
meta: { title: '后台管理', requiresAuth: true },
redirect: '/admin/dashboard',
children: [
// 首页仪表盘
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/admin/Dashboard.vue'),
meta: { title: '首页' },
},
// 个人中心
{
path: 'personal',
name: 'PersonalView',
component: () => import('@/views/admin/PersonalView.vue'),
meta: { title: '个人中心' },
},
// --- 系统配置 ---
{
path: 'config',
name: 'SystemConfig',
component: () => import('@/views/admin/config/SystemConfig.vue'),
meta: { title: '系统配置' },
},
// --- 表单路由(独立页面,放在列表路由前面避免被匹配) ---
{
path: 'system/form/:id?',
name: 'SystemForm',
component: () => import('@/views/admin/system/SystemForm.vue'),
meta: { title: '系统表单' },
},
{
path: 'user/form/:id?',
name: 'UserForm',
component: () => import('@/views/admin/user/UserForm.vue'),
meta: { title: '用户表单' },
},
{
path: 'role/form/:id?',
name: 'RoleForm',
component: () => import('@/views/admin/role/RoleForm.vue'),
meta: { title: '角色表单' },
},
{
path: 'menu/form/:id?',
name: 'MenuForm',
component: () => import('@/views/admin/menu/MenuForm.vue'),
meta: { title: '菜单表单' },
},
{
path: 'dict/form/:id?',
name: 'DictForm',
component: () => import('@/views/admin/dict/DictForm.vue'),
meta: { title: '字典表单' },
},
{
path: 'api/form/:id?',
name: 'ApiForm',
component: () => import('@/views/admin/system/ApiForm.vue'),
meta: { title: '接口表单' },
},
// --- 列表路由 ---
{
path: 'system',
name: 'SystemList',
component: () => import('@/views/admin/system/SystemList.vue'),
meta: { title: '系统管理' },
},
{
path: 'user',
name: 'UserList',
component: () => import('@/views/admin/user/UserList.vue'),
meta: { title: '用户管理' },
},
{
path: 'role',
name: 'RoleList',
component: () => import('@/views/admin/role/RoleList.vue'),
meta: { title: '角色管理' },
},
{
path: 'menu',
name: 'MenuList',
component: () => import('@/views/admin/menu/MenuList.vue'),
meta: { title: '菜单管理' },
},
{
path: 'log/login',
name: 'LoginLogList',
component: () => import('@/views/admin/log/LoginLogList.vue'),
meta: { title: '登录日志' },
},
{
path: 'log/operation',
name: 'OperationLogList',
component: () => import('@/views/admin/log/OperationLogList.vue'),
meta: { title: '操作日志' },
},
{
path: 'log/token',
name: 'OpenTokenLogList',
component: () => import('@/views/admin/log/OpenTokenLogList.vue'),
meta: { title: 'Token日志' },
},
{
path: 'api',
name: 'ApiList',
component: () => import('@/views/admin/system/ApiList.vue'),
meta: { title: '接口管理' },
},
{
path: 'dict',
name: 'DictList',
component: () => import('@/views/admin/dict/DictList.vue'),
meta: { title: '数据字典' },
},
{
path: 'file',
name: 'FileList',
component: () => import('@/views/admin/file/FileList.vue'),
meta: { title: '文件管理' },
},
{
path: 'email',
name: 'EmailList',
component: () => import('@/views/admin/email/EmailList.vue'),
meta: { title: '邮件管理' },
},
],
},
// 系统接入文档(公开,无需登录)
{
path: '/docs/:token',
name: 'IntegrationDoc',
component: () => import('@/views/docs/IntegrationDoc.vue'),
meta: { title: '系统接入文档' },
},
{
path: '/',
redirect: '/login',
},
],
})
// Cookie token 读取(与 request.ts / auth.ts 一致)
function getCookieToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
// 设置默认 faviconMokee 三角 SVG
function setDefaultFavicon() {
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement
if (link) {
link.href = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>'
)
}
}
// 更新浏览器标题和 favicon
function updateHead(title: string, iconUrl?: string) {
document.title = title ? title + ' | Mokee Gateway' : 'Mokee Gateway'
const link = document.querySelector("link[rel='icon']") as HTMLLinkElement
if (!link) return
if (iconUrl && (iconUrl.startsWith('http') || iconUrl.startsWith('/zgapi'))) {
link.href = iconUrl
} else {
setDefaultFavicon()
}
}
// 全局路由守卫
router.beforeEach((to, _from, next) => {
updateHead(to.meta.title as string)
const token = getCookieToken()
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/select')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,150 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '@/utils/request'
/** 文件对象存储服务地址 */
const FILE_SERVICE_HOST = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com'
/** 判断 URL 是否为文件服务地址(需要动态 Token */
function isFileServiceUrl(url: string): boolean {
if (!url) return false
return url.includes('file.wg.zgitm.com') || url.includes(FILE_SERVICE_HOST.replace('https://', '').replace('http://', ''))
}
/** 从文件服务 URL 中提取 baseUrl去掉 ?token=xxx 参数) */
function stripToken(url: string): string {
if (!url) return url
return url.split('?')[0]
}
/** 为文件服务 URL 获取新 Token 并拼接完整地址 */
async function refreshFileServiceUrl(url: string): Promise<string> {
if (!url || !isFileServiceUrl(url)) return url
try {
const baseUrl = stripToken(url)
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const token = tokenRes.data?.token
if (!token) return url
return `${baseUrl}?token=${token}`
} catch {
return url
}
}
/** Cookie token 读写 (与 request.ts 保持一致) */
const TOKEN_KEY = 'token'
const COOKIE_DOMAIN = import.meta.env.VITE_COOKIE_DOMAIN || ''
function getToken(): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
function setToken(val: string) {
const domainPart = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : ''
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(val)};path=/;max-age=7200;SameSite=Lax${domainPart}`
}
function removeToken() {
const domainPart = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : ''
document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}`
}
/** 用户信息 */
export interface UserInfo {
userId: string; username: string; realName: string; email: string
phone: string; avatar: string; post: string
permissions?: string[]
}
/** 业务系统 */
export interface SystemInfo {
systemId: string; systemCode: string; systemName: string
frontUrl: string; icon: string; description: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(getToken() || '')
const userInfo = ref<UserInfo | null>(null)
const systemList = ref<SystemInfo[]>([])
const currentSystem = ref<SystemInfo | null>(null)
const isAdmin = ref<boolean>(false)
const permissions = ref<string[]>([])
async function login(username: string, password: string, systemCode?: string) {
const res = await request.post('/zgapi/v1/auth/login', { username, password, systemCode })
const data = res.data
token.value = data.token
setToken(data.token)
// 立即刷新头像 Token — 否则跳转后 <el-avatar> 先用无 Token 的 URL
// 发起一次失败请求,等 fetchUserInfo() 拿到 Token 后又发起第二次
// fetchUserInfo() 中有去重逻辑,不会重复刷新同一文件
let userInfoData = data.userInfo
if (userInfoData?.avatar && isFileServiceUrl(userInfoData.avatar)) {
userInfoData = { ...userInfoData, avatar: await refreshFileServiceUrl(userInfoData.avatar) }
}
userInfo.value = userInfoData
systemList.value = data.systemList || []
permissions.value = data.permissions || []
currentSystem.value = data.currentSystem || null
isAdmin.value = data.isAdmin || false
return data
}
async function logout() {
try { await request.post('/zgapi/v1/auth/logout') } catch {}
removeToken()
token.value = ''
userInfo.value = null
systemList.value = []
currentSystem.value = null
permissions.value = []
}
async function selectSystem(systemId: string) {
await request.post('/zgapi/v1/auth/system/select', null, { params: { systemId } })
const sys = systemList.value.find(s => s.systemId === systemId)
if (sys) currentSystem.value = sys
}
async function fetchUserInfo() {
const res = await request.get('/zgapi/v1/auth/user/info')
let data = res.data
// 若头像地址来自文件服务,先刷新 Token 再设置到响应式变量
// (避免 userInfo 先用旧 URL 触发一次失败的 <img> 加载,等新 Token 到了再触发第二次)
// 但如果当前 store 中已有同一文件的头像(带 Token跳过刷新避免页面切换时重复调用 /open/token
const prevBase = stripToken(userInfo.value?.avatar || '')
const newBase = stripToken(data?.avatar || '')
if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase !== newBase) {
data = { ...data, avatar: await refreshFileServiceUrl(data.avatar) }
} else if (data?.avatar && isFileServiceUrl(data.avatar) && prevBase === newBase) {
// 同一文件,复用已有的带 Token 的 URL避免重复调用 /open/token 和下载接口
data = { ...data, avatar: userInfo.value?.avatar || data.avatar }
}
userInfo.value = data
if (data?.permissions) {
permissions.value = data.permissions
}
}
async function fetchMenuRouter(systemCode: string) {
const res = await request.get('/zgapi/v1/auth/menu/router', { params: { systemCode } })
// 返回结构: { menu: MenuRouterVO[], permission: string[] }
const data = res.data
if (data?.permission) {
permissions.value = data.permission
}
return data
}
async function fetchSystemList() {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data || []
}
function hasPermission(perm: string): boolean {
return permissions.value.includes(perm)
}
return {
token, userInfo, systemList, currentSystem, isAdmin, permissions,
login, logout, selectSystem, fetchUserInfo, fetchMenuRouter, fetchSystemList, hasPermission,
}
})

View File

@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
/** Tab 标签项 */
export interface TabItem {
path: string // 路由路径,唯一标识
title: string // 标签显示名称
query?: any // 路由参数
}
export const useTabsStore = defineStore('tabs', () => {
const tabs = ref<TabItem[]>([])
const activeTab = ref<string>('')
/** 添加或激活一个 Tab */
function addTab(route: RouteLocationNormalized) {
const title = (route.meta.title as string) || route.name as string || route.path
const key = route.path
// 已存在则激活
const exist = tabs.value.find(t => t.path === key)
if (exist) {
activeTab.value = key
return
}
tabs.value.push({ path: key, title, query: route.query })
activeTab.value = key
}
/** 关闭一个 Tab */
function removeTab(path: string) {
const idx = tabs.value.findIndex(t => t.path === path)
if (idx === -1) return
tabs.value.splice(idx, 1)
// 如果关闭的是当前激活的 Tab激活相邻的
if (activeTab.value === path) {
if (tabs.value.length === 0) {
activeTab.value = ''
return
}
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
activeTab.value = next.path
}
}
/** 关闭其他 Tab */
function closeOthers(path: string) {
tabs.value = tabs.value.filter(t => t.path === path)
activeTab.value = path
}
/** 关闭所有 Tab */
function closeAll() {
tabs.value = []
activeTab.value = ''
}
/** 当前激活的 Tab 路径 */
const currentPath = computed(() => activeTab.value)
return { tabs, activeTab, currentPath, addTab, removeTab, closeOthers, closeAll }
})

View File

@@ -0,0 +1,79 @@
/**
* 上传队列 Store — 支持 localStorage 持久化
*
* 刷新页面后上传连接会中断HTTP 无法跨页面生命周期),
* 但队列状态保留在 localStorage 中,重新加载后可查看和重试。
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface UploadTask {
id: string
fileName: string
fileSize: number
fileType: string
file: File | null // 内存中的 File 对象(刷新后丢失)
status: 'queued' | 'uploading' | 'completed' | 'failed' | 'interrupted'
progress: number
speed: string
fileId?: string // 上传成功后返回的 ID
error?: string
startedAt: number // 开始时间戳
}
const STORAGE_KEY = 'mokee_upload_queue'
function loadQueue(): UploadTask[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const arr = JSON.parse(raw) as UploadTask[]
// 刷新后仍在"上传中"的标记为中断
return arr.map((t: UploadTask) => t.status === 'uploading' ? { ...t, status: 'interrupted' as any, file: null, speed: '' } as UploadTask : { ...t, file: null } as UploadTask)
} catch { return [] }
}
function saveQueue(tasks: UploadTask[]) {
// 只持久化可序列化的字段file 对象不持久化)
const serializable = tasks.map(({ file, ...rest }) => rest)
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable))
}
export const useUploadStore = defineStore('upload', () => {
const tasks = ref<UploadTask[]>(loadQueue())
const activeCount = computed(() => tasks.value.filter(t => t.status === 'uploading' || t.status === 'queued').length)
const failedCount = computed(() => tasks.value.filter(t => t.status === 'failed' || t.status === 'interrupted').length)
const panelVisible = ref(false)
function addTask(task: UploadTask) {
tasks.value.unshift(task)
saveQueue(tasks.value)
}
function updateTask(id: string, patch: Partial<UploadTask>) {
const t = tasks.value.find(t => t.id === id)
if (t) {
Object.assign(t, patch)
saveQueue(tasks.value)
}
}
function removeTask(id: string) {
tasks.value = tasks.value.filter(t => t.id !== id)
saveQueue(tasks.value)
}
function clearCompleted() {
tasks.value = tasks.value.filter(t => t.status === 'uploading' || t.status === 'queued')
saveQueue(tasks.value)
}
/** 清掉已完成/失败/中断的任务 */
function clearAll() {
tasks.value = []
saveQueue(tasks.value)
}
return { tasks, activeCount, failedCount, panelVisible, addTask, updateTask, removeTask, clearCompleted, clearAll }
})

View File

@@ -0,0 +1,450 @@
/* ============================================
Mokee Gateway — 现代简约设计系统 v2
============================================ */
:root {
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-primary-light: #eef2ff;
--color-primary-bg: rgba(99, 102, 241, 0.08);
--color-success: #10b981;
--color-success-light: #ecfdf5;
--color-warning: #f59e0b;
--color-warning-light: #fffbeb;
--color-danger: #ef4444;
--color-danger-light: #fef2f2;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--bg-page: #f8fafc;
--bg-surface: #ffffff;
--bg-hover: #f1f5f9;
--border-color: #e2e8f0;
--border-light: #f1f5f9;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-xs: 0 1px 2px rgba(0,0,0,0.03);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(0,0,0,0.03);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.03);
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
}
/* ===== 重置 ===== */
* { margin:0; padding:0; box-sizing:border-box; }
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 15px;
color: var(--text-primary);
background: var(--bg-page);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width:5px; height:5px; }
::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:10px; }
::-webkit-scrollbar-thumb:hover { background:#94a3b8; }
::-webkit-scrollbar-track { background:transparent; }
/* ===== 按钮:彻底重置 ===== */
.el-button {
font-size: 14px;
font-weight: 500;
height: 36px;
padding: 0 16px;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
background: #fff;
color: var(--text-secondary);
box-shadow: none;
}
.el-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: #cbd5e1;
}
.el-button:active {
background: var(--bg-hover);
}
/* primary */
.el-button--primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.el-button--primary:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: #fff;
}
/* success / warning / danger */
.el-button--success { background: var(--color-success); border-color: var(--color-success); color: #fff; }
.el-button--warning { background: var(--color-warning); border-color: var(--color-warning); color: #fff; }
.el-button--danger { background: var(--color-danger); border-color: var(--color-danger); color: #fff; }
/* small */
.el-button--small {
font-size: 13px;
height: 32px;
padding: 0 12px;
border-radius: var(--radius-sm);
}
/* --- 按钮组:彻底分离 --- */
.el-button-group {
display: inline-flex;
gap: 8px;
}
.el-button-group .el-button {
border-radius: var(--radius-sm) !important;
margin: 0 !important;
}
.el-button-group .el-button + .el-button {
margin-left: 0 !important;
}
/* ===== 输入框 ===== */
.el-input__wrapper {
border-radius: var(--radius-md) !important;
box-shadow: 0 0 0 1px var(--border-color) inset !important;
transition: all var(--transition-fast);
font-size: 14px;
}
.el-input.is-focus .el-input__wrapper {
box-shadow: 0 0 0 2px rgba(99,102,241,0.2) inset !important;
}
.el-input--large .el-input__wrapper { font-size: 15px; }
/* ===== 表格 ===== */
.el-table {
font-size: 14px;
--el-table-border-color: var(--border-color);
}
.el-table th.el-table__cell {
background: #f8fafc !important;
color: var(--text-secondary);
font-weight: 600;
font-size: 13px;
height: 46px;
border-bottom: 2px solid var(--border-color);
}
.el-table td.el-table__cell {
padding: 12px 0;
font-size: 14px;
}
.el-table--striped .el-table__body tr.el-table__row--striped td {
background: #fafbfc;
}
.el-table__body tr:hover > td {
background: #f8fafc !important;
}
/* ===== 卡片 ===== */
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-sm) !important;
}
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
font-weight: 600;
font-size: 15px;
}
.el-card__body { padding: 20px; }
/* ===== 标签 ===== */
.el-tag {
border-radius: 20px;
font-weight: 500;
font-size: 13px;
padding: 0 10px;
height: 26px;
line-height: 26px;
border: none;
}
.el-tag--success { background: var(--color-success-light); color: #059669; }
.el-tag--danger { background: var(--color-danger-light); color: #dc2626; }
.el-tag--warning { background: var(--color-warning-light); color: #d97706; }
.el-tag--primary { background: var(--color-primary-light); color: var(--color-primary); }
/* ===== 对话框 ===== */
.el-dialog {
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.el-dialog__header { padding: 20px 24px 16px; border-bottom: 1px solid var(--border-light); }
.el-dialog__title { font-size: 17px; font-weight: 600; }
.el-dialog__body { padding: 24px; }
.el-dialog__footer { padding: 12px 24px 20px; }
/* ===== 分页 ===== */
.el-pagination { font-size: 14px; }
.el-pagination .el-pager li {
font-size: 14px;
min-width: 32px;
height: 32px;
border-radius: var(--radius-sm);
}
.el-pagination .el-pager li.is-active {
background: var(--color-primary) !important;
color: #fff;
}
/* ===== 菜单 ===== */
.el-menu { border-right: none !important; }
/* ===== 下拉菜单 ===== */
.el-dropdown-menu {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
padding: 4px;
}
.el-dropdown-menu__item {
border-radius: var(--radius-sm);
padding: 9px 14px;
font-size: 14px;
}
/* ===== 面包屑 ===== */
.el-breadcrumb { font-size: 14px; }
.el-breadcrumb__inner { color: var(--text-muted); font-weight: 500; }
.el-breadcrumb__inner.is-link:hover { color: var(--color-primary); }
/* ===== 表单 ===== */
.el-form-item__label {
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
}
.el-form-item { margin-bottom: 20px; }
/* ===== 空状态 ===== */
.el-empty__description { color: var(--text-muted); font-size: 14px; }
/* ===== 消息 ===== */
.el-message {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
border: none;
font-size: 14px;
z-index: 9999;
}
/* 消息容器Element Plus 动态创建的包装层) */
.el-message-fade-enter-from,
.el-message-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-16px);
}
/* ===== 描述列表 ===== */
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
font-weight: 500;
color: var(--text-secondary);
background: #f8fafc;
}
/* ===== 列表页通用布局 ===== */
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
/* ===== 移动端适配 (iPhone 15: 393px, 通用断点: 768px) ===== */
@media (max-width: 768px) {
/* ===== 手机端卡片列表 ===== */
.mobile-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 14px;
box-shadow: var(--shadow-xs);
}
.card-field {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 6px 0;
border-bottom: 1px solid var(--border-light);
font-size: 13px;
line-height: 1.6;
gap: 12px;
}
.card-field:last-of-type { border-bottom: none; }
.fld-label {
font-weight: 600;
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
min-width: 56px;
}
.fld-val {
color: var(--text-primary);
text-align: right;
word-break: break-word;
}
/* 卡片中的标签 */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
}
/* 卡片底部的操作按钮 */
.card-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border-light);
}
.card-actions .el-button {
font-size: 12px;
height: 32px;
padding: 0 12px;
}
/* ===== 表单底部按钮:手机端上下排列,等宽 ===== */
.form-footer {
flex-direction: column !important;
gap: 8px !important;
}
.form-footer .el-button {
width: 100% !important;
margin-left: 0 !important;
}
/* 桌面端表格正常滚动 */
.el-card > .el-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* ===== 操作列:桌面按钮行隐藏,移动端下拉显示 ===== */
.actions-desk { display: none !important; }
.actions-mob { display: inline-flex !important; }
.ops-col {
width: 60px !important;
min-width: 60px !important;
}
/* 弹窗接近全屏 */
.el-dialog {
width: 95vw !important;
max-width: 95vw !important;
margin-top: 5vh !important;
border-radius: var(--radius-lg) !important;
}
.el-dialog__header { padding: 14px 16px 10px; }
.el-dialog__title { font-size: 16px; }
.el-dialog__body { padding: 14px; max-height: 65vh; overflow-y: auto; }
.el-dialog__footer { padding: 10px 16px 14px; }
/* 分页 — 居中 + 隐藏 jumper/sizes */
.el-pagination {
justify-content: center;
}
.el-pagination .el-pagination__jump {
display: none;
}
.el-pagination__sizes {
display: none;
}
/* 表单 — label 在上方 */
.el-form-item {
display: block;
margin-bottom: 14px;
}
.el-form-item__label {
width: auto !important;
text-align: left;
padding-bottom: 2px;
}
.el-form-item__content {
margin-left: 0 !important;
flex: 1;
}
/* 卡片间距缩小 */
.page-wrapper { gap: 10px; }
.el-card__body { padding: 14px; }
.el-card__header { padding: 12px 14px; font-size: 14px; }
/* 按钮间距 */
.el-button + .el-button { margin-left: 6px; }
/* 搜索卡片 — inline 表单 → 垂直排列 */
.search-card .el-form--inline {
display: flex;
flex-wrap: wrap;
gap: 0;
}
.search-card .el-form--inline .el-form-item {
margin-right: 0;
width: 100%;
}
.search-card .el-form--inline .el-input,
.search-card .el-form--inline .el-select,
.search-card .el-form--inline .el-date-picker {
width: 100% !important;
}
.search-card .el-form--inline .el-form-item:last-child .el-button {
width: 100%;
margin-left: 0;
}
.search-card .el-form--inline .el-form-item:last-child .el-button + .el-button {
margin-top: 8px;
}
}
/* 小屏手机 (iPhone SE, etc) */
@media (max-width: 480px) {
.el-message {
min-width: auto;
max-width: 90vw;
padding: 10px 16px;
}
}

View File

@@ -0,0 +1,61 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
IconPicker: typeof import('./../components/IconPicker.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UploadPanel: typeof import('./../components/UploadPanel.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,127 @@
/**
* 日期时间格式化工具
*
* 统一前端所有日期时间显示格式:
* - 日期+时间yyyy-MM-dd HH:mm:ss
* - 仅日期: yyyy-MM-dd
*
* 使用方式:
* import { formatDateTime, formatDate } from '@/utils/date'
* formatDateTime(row.createdAt) // "2026-06-13 09:15:30"
* formatDate(row.createdAt) // "2026-06-13"
*
* 设计要点:
* 后端 JacksonConfig 已将 Date 统一序列化为 yyyy-MM-dd HH:mm:ss 格式,
* 前端不做 new Date() 解析(避免浏览器差异),直接操作字符串。
*/
/** 补零到指定位数 */
function pad(n: number, len = 2): string {
return String(n).padStart(len, '0')
}
/**
* 从日期字符串中提取 [年, 月, 日, 时, 分, 秒] 数组
* 兼容格式:
* - yyyy-MM-dd HH:mm:ss (后端标准格式)
* - yyyy-MM-ddTHH:mm:ss ISO 8601 不带时区)
* - yyyy-MM-ddTHH:mm:ss.SSS (带毫秒)
* - yyyy-MM-ddTHH:mm:ss+XX:XX (带时区偏移)
* - yyyy-MM-dd 仅日期时分秒补0
* - 时间戳数字字符串
* @returns 提取成功返回 [y, m, d, h, min, s],失败返回 null
*/
function extractDateParts(value: string): [number, number, number, number, number, number] | null {
const s = value.trim()
if (!s) return null
// 1. 先匹配完整的 yyyy-MM-dd HH:mm:ss后端标准输出
let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?$/)
if (m) {
return [+m[1], +m[2], +m[3], +m[4], +m[5], +m[6]]
}
// 2. 仅日期 yyyy-MM-dd
m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/)
if (m) {
return [+m[1], +m[2], +m[3], 0, 0, 0]
}
// 3. 纯数字(时间戳毫秒)
if (/^\d{9,13}$/.test(s)) {
const ts = +s
const d = new Date(ts)
if (!isNaN(d.getTime())) {
return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()]
}
}
// 4. 降级:用 new Date() 尝试解析(处理其他未知格式)
const d = new Date(s)
if (!isNaN(d.getTime())) {
return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()]
}
return null
}
/**
* 格式化日期时间为 yyyy-MM-dd HH:mm:ss
* @param value 日期字符串 / 时间戳毫秒 / Date 对象
* @returns 格式化后的字符串,解析失败返回 "-"
*/
export function formatDateTime(value: string | number | Date | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
// Date 对象
if (value instanceof Date) {
if (isNaN(value.getTime())) return '-'
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
}
// 数字 — 时间戳
if (typeof value === 'number') {
const d = new Date(value)
if (isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// 字符串 — 手动解析
const parts = extractDateParts(value)
if (parts) {
return `${parts[0]}-${pad(parts[1])}-${pad(parts[2])} ${pad(parts[3])}:${pad(parts[4])}:${pad(parts[5])}`
}
// 彻底解析失败,返回原值(比返回 "-" 更有信息量)
return value
}
/**
* 格式化日期为 yyyy-MM-dd只取日期部分忽略时间
* @param value 日期字符串 / 时间戳毫秒 / Date 对象
* @returns 格式化后的日期字符串,解析失败返回 "-"
*/
export function formatDate(value: string | number | Date | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
// Date 对象
if (value instanceof Date) {
if (isNaN(value.getTime())) return '-'
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
}
// 数字 — 时间戳
if (typeof value === 'number') {
const d = new Date(value)
if (isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
// 字符串 — 手动解析
const parts = extractDateParts(value)
if (parts) {
return `${parts[0]}-${pad(parts[1])}-${pad(parts[2])}`
}
return value
}

View File

@@ -0,0 +1,22 @@
/**
* 图标注册表 — 动态图标解析
*
* 因为 unplugin-vue-components 按需导入,模板中 `<component :is="iconName">` 无法通过字符串找到图标。
* 这里导入全部 Element Plus 图标建立字符串→组件映射,供系统配置表、菜单图标等动态渲染场景使用。
*
* 使用场景SystemList、SystemSelectView、MenuList、IntegrationDoc 等动态图标渲染
*/
import * as Icons from '@element-plus/icons-vue'
/**
* 根据图标名称获取图标组件
* @param name - 图标名称(如 "Setting"、"UserFilled"
* @returns 图标组件对象,找不到返回 null
*/
export function getIconComponent(name: string | undefined | null) {
if (!name) return null
return (Icons as Record<string, any>)[name] || null
}
export { Icons }
export default Icons

View File

@@ -0,0 +1,69 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: import.meta.env.VITE_GATEWAY_URL || '',
timeout: 60000,
})
// Token 读写Cookie 跨子域共享,域名通过 VITE_COOKIE_DOMAIN 环境变量配置)
const COOKIE_DOMAIN = import.meta.env.VITE_COOKIE_DOMAIN || ''
const DOMAIN_PART = COOKIE_DOMAIN ? `;domain=${COOKIE_DOMAIN}` : ''
function getToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
function setToken(val: string) {
document.cookie = `token=${encodeURIComponent(val)};path=/;max-age=7200;SameSite=Lax${DOMAIN_PART}`
}
function removeToken() {
document.cookie = `token=;path=/;max-age=0${DOMAIN_PART}`
}
// 请求拦截器
service.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
service.interceptors.response.use(
(response) => {
const res = response.data
if (res.code && res.code !== 200) {
ElMessage.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg))
}
return res
},
(error) => {
if (error.response) {
const status = error.response.status
if (status === 401) {
const hadToken = !!getToken()
removeToken()
if (hadToken) {
window.location.href = (import.meta.env.VITE_LOGIN_URL as string) || '/login'
}
} else if (status === 403) {
ElMessage.error('无访问权限')
} else if (status === 429) {
ElMessage.error('操作太频繁,请稍后再试')
} else {
ElMessage.error(error.response.data?.msg || '服务器错误')
}
} else {
ElMessage.error('网络连接异常')
}
return Promise.reject(error)
}
)
export default service

View File

@@ -0,0 +1,564 @@
<template>
<div class="admin-shell">
<!-- ===== 侧边栏树形菜单 ===== -->
<aside class="sidebar" :class="{ open: mobileSidebarOpen }">
<!-- Logo -->
<div class="sidebar-logo" @click="router.push('/admin/dashboard')">
<svg class="logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="logo-text">Mokee</span>
</div>
<!-- 菜单树动态渲染完全由 API 根据角色返回菜单 -->
<nav class="sidebar-nav">
<!-- 菜单加载失败或无权限时显示提示 -->
<div v-if="!menuTree.length" class="menu-empty">
<span>暂无菜单</span>
</div>
<!-- 动态菜单 API 返回目录(path=null)和菜单(path!=null) -->
<template v-for="(node, idx) in menuTree" :key="idx">
<!-- 目录节点path 为空 = 有子菜单的目录可展开折叠 -->
<div v-if="!node.path && node.children && node.children.length" class="menu-group" :class="{ open: menuOpen[idx] !== false }">
<div class="menu-item parent" @click="menuOpen[idx] = menuOpen[idx] === false ? true : false">
<el-icon :size="18"><component :is="getIcon(node.meta?.icon)" /></el-icon>
<span>{{ node.meta?.title || node.name }}</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen[idx] !== false }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen[idx] !== false">
<div
v-for="(child, cidx) in node.children"
:key="cidx"
class="menu-item child"
:class="{ active: activeMenu === child.path }"
@click="openPage(child.path, child.meta?.title || child.name)"
>
<span>{{ child.meta?.title || child.name }}</span>
</div>
</div>
</div>
<!-- 顶层菜单节点path 不为空直接可点击 -->
<div
v-else-if="node.path"
class="menu-item single"
:class="{ active: activeMenu === node.path }"
@click="openPage(node.path, node.meta?.title || node.name)"
>
<el-icon :size="18"><component :is="getIcon(node.meta?.icon)" /></el-icon>
<span>{{ node.meta?.title || node.name }}</span>
</div>
</template>
</nav>
<!-- 底部 -->
<div class="sidebar-footer">
<div class="menu-item single" @click="router.push('/select')">
<el-icon :size="18"><Switch /></el-icon>
<span>切换系统</span>
</div>
</div>
</aside>
<!-- 移动端遮罩 -->
<div :class="['sidebar-overlay', { show: mobileSidebarOpen }]" @click="mobileSidebarOpen = false" />
<!-- ===== 右侧主体 ===== -->
<div class="main-area">
<!-- 顶栏 -->
<header class="topbar">
<div class="topbar-left">
<span class="hamburger" @click="mobileSidebarOpen = !mobileSidebarOpen">
<el-icon :size="20"><component :is="mobileSidebarOpen ? Fold : Expand" /></el-icon>
</span>
<el-breadcrumb separator="/" class="breadcrumb-desk">
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbParent">{{ breadcrumbParent }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbCurrent">{{ breadcrumbCurrent }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="topbar-right">
<el-dropdown trigger="hover" placement="bottom-end">
<div class="user-chip">
<el-avatar :size="28" :src="authStore.userInfo?.avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<span class="user-name">{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}</span>
<el-icon :size="12" class="user-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/admin/personal')">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item @click="router.push('/select')">
<el-icon><Switch /></el-icon>切换系统
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 标签栏 -->
<div class="tab-bar" v-if="tabsStore.tabs.length > 0">
<div class="tab-list" ref="tabListRef">
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
:class="['tab-chip', { active: tabsStore.activeTab === tab.path }]"
@click="switchTab(tab)"
@contextmenu.prevent="showMenu($event, tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<el-icon
v-if="tabsStore.tabs.length > 1"
class="tab-close"
:size="12"
@click.stop="closeTab(tab.path)"
><Close /></el-icon>
</div>
</div>
<el-dropdown trigger="click" @command="handleTabCmd" class="tab-more">
<span class="tab-more-btn">
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">
<el-icon><Close /></el-icon>关闭当前
</el-dropdown-item>
<el-dropdown-item command="closeOthers">
<el-icon><Remove /></el-icon>关闭其他
</el-dropdown-item>
<el-dropdown-item command="closeAll">
<el-icon><CircleClose /></el-icon>关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="ctxMenu.visible"
class="ctx-overlay"
@click="ctxMenu.visible = false"
>
<div
class="ctx-menu"
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
@click.stop
>
<div class="ctx-item" @click="closeTab(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Close /></el-icon>关闭
</div>
<div class="ctx-item" @click="closeOthers(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Remove /></el-icon>关闭其他
</div>
<div class="ctx-item" @click="closeAll(); ctxMenu.visible = false">
<el-icon :size="14"><CircleClose /></el-icon>关闭全部
</div>
</div>
</div>
</Teleport>
<!-- 内容区 -->
<main class="content">
<router-view :key="tabsStore.activeTab" />
</main>
<!-- 底部备案号 -->
<footer class="footer-icp">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">湘ICP备19021539号</a>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
HomeFilled, Monitor, UserFilled, Document,
ArrowRight, Switch, ArrowDown, SwitchButton,
User, Close, Remove, CircleClose, Expand, Fold,
Setting, Connection, Tickets, Avatar, List, Files, Message, Key, Link,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useTabsStore } from '@/stores/tabs'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const tabsStore = useTabsStore()
const tabListRef = ref<HTMLElement>()
const mobileSidebarOpen = ref(false)
/** 从 API 加载的菜单树 — API 返回格式:
* 目录节点: { name, path: null, meta: {title, icon}, children: [...] }
* 菜单节点: { name, path: "/admin/xxx", meta: {title, icon}, children: null }
*/
interface MenuNode {
name: string; path: string | null; meta?: { title?: string; icon?: string }; children?: MenuNode[]
}
const menuTree = ref<MenuNode[]>([])
/** 菜单展开状态key 为菜单组 id */
const menuOpen = reactive<Record<string, boolean>>({})
/** 菜单名称 → 图标映射 */
const iconMap: Record<string, any> = {
'Setting': Setting, 'Monitor': Monitor, 'Document': Document,
'User': User, 'UserFilled': UserFilled, 'Avatar': Avatar,
'Tickets': Tickets, 'Files': Files, 'Message': Message,
'Key': Key, 'Link': Link, 'Connection': Connection,
'List': List, 'ArrowRight': ArrowRight, 'Switch': Switch,
'HomeFilled': HomeFilled,
}
function getIcon(name: string): any {
return iconMap[name] || List
}
// 当前高亮 — 根据路由匹配菜单路径
const activeMenu = computed(() => {
const p = route.path
// 从菜单树中找到所有叶子菜单的路径
const allPaths = collectPaths(menuTree.value)
return allPaths.find(k => p.startsWith(k)) || p
})
function collectPaths(nodes: MenuNode[]): string[] {
const paths: string[] = []
for (const n of nodes) {
if (n.path) paths.push(n.path)
if (n.children && n.children.length) paths.push(...collectPaths(n.children))
}
return paths
}
// 面包屑 — 根据菜单树动态构建
const breadcrumbParent = computed(() => {
return findBreadcrumb(menuTree.value, route.path)?.parent
})
const breadcrumbCurrent = computed(() => {
return findBreadcrumb(menuTree.value, route.path)?.current
})
function findBreadcrumb(nodes: MenuNode[], path: string, parentName?: string): { parent?: string; current: string } | null {
for (const n of nodes) {
if (n.path && path.startsWith(n.path)) {
return { parent: parentName, current: n.meta?.title || n.name }
}
if (n.children && n.children.length) {
const found = findBreadcrumb(n.children, path, n.meta?.title || n.name)
if (found) return found
}
}
return null
}
// ===== 标签页 =====
function openPage(path: string, title: string) {
tabsStore.addTab({ path, meta: { title }, query: {} } as any)
router.push(path)
mobileSidebarOpen.value = false
}
function switchTab(tab: any) {
router.push(tab.path)
}
function closeTab(path: string) {
tabsStore.removeTab(path)
if (tabsStore.activeTab) {
router.push(tabsStore.activeTab)
} else {
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
}
function closeOthers(path?: string) {
if (path) tabsStore.closeOthers(path)
}
function closeAll() {
tabsStore.closeAll()
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
function handleTabCmd(cmd: string) {
const cur = tabsStore.activeTab
if (cmd === 'closeCurrent' && cur) closeTab(cur)
else if (cmd === 'closeOthers' && cur) closeOthers(cur)
else if (cmd === 'closeAll') closeAll()
}
// 右键菜单
const ctxMenu = reactive({ visible: false, x: 0, y: 0, tab: null as any })
function showMenu(e: MouseEvent, tab: any) {
ctxMenu.visible = true
ctxMenu.x = e.clientX
ctxMenu.y = e.clientY
ctxMenu.tab = tab
}
// 路由变化自动加标签
watch(() => route.path, (p) => {
if (p.startsWith('/admin') && p !== '/admin') {
tabsStore.addTab(route)
}
})
onMounted(async () => {
// 跨域名跳转后 Pinia 状态丢失,重新从服务端加载用户信息
await authStore.fetchUserInfo()
// 加载菜单树根据当前用户角色过滤API 已包含 首页 节点)
try {
const tree = await authStore.fetchMenuRouter('gateway')
menuTree.value = tree?.menu || []
// 初始化所有目录为展开状态(目录: path 为 null 且有子节点)
menuTree.value.forEach((n, i) => {
if (!n.path && n.children) menuOpen[i] = true
})
} catch { /* 菜单加载失败不影响页面渲染 */ }
if (route.path !== '/admin' && route.path !== '/admin/dashboard') {
tabsStore.addTab(route)
}
// 首页默认标签
if (!tabsStore.tabs.find((t: any) => t.path === '/admin/dashboard')) {
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
if (route.path === '/admin') {
router.push('/admin/dashboard')
}
})
async function handleLogout() {
try { await authStore.logout() } catch {}
tabsStore.closeAll()
// 直接完整跳转登录页,避免 router.push 触发组件 API 请求
window.location.href = import.meta.env.VITE_LOGIN_URL || '/login'
}
</script>
<style scoped>
/* ===== 整体布局 ===== */
.admin-shell { display:flex; height:100vh; overflow:hidden; background:var(--bg-page); }
/* ===== 侧边栏 ===== */
.sidebar {
width: 240px; flex-shrink:0;
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display:flex; flex-direction:column; overflow:hidden; z-index:50;
}
.sidebar-logo {
display:flex; align-items:center; gap:10px;
height: 58px; padding:0 20px;
border-bottom: 1px solid var(--border-light);
cursor:pointer; flex-shrink:0;
}
.logo-svg { width:22px; height:22px; color:var(--color-primary); flex-shrink:0; }
.logo-text { font-size:18px; font-weight:700; color:var(--text-primary); letter-spacing:-0.3px; }
.sidebar-nav { flex:1; overflow-y:auto; padding:16px 12px; }
.sidebar-footer { padding:8px 12px 16px; border-top:1px solid var(--border-light); flex-shrink:0; }
/* ===== 菜单项 ===== */
.menu-item {
display:flex; align-items:center; gap:10px;
height:42px; padding:0 14px;
border-radius: var(--radius-md);
cursor:pointer; font-size:14px; font-weight:500;
color: var(--text-secondary);
transition: all var(--transition-fast);
white-space:nowrap;
}
.menu-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.menu-item.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:600; }
.menu-item.single { margin-bottom:4px; }
.menu-item.parent {
font-weight:600; font-size:14px; color:var(--text-primary);
margin-top:2px;
}
.menu-item.child {
padding-left:48px; height:38px;
font-size:14px; font-weight:400;
color: var(--text-muted);
margin:1px 0;
}
.menu-item.child:hover { color:var(--text-primary); }
.menu-item.child.active { color:var(--color-primary); font-weight:500; }
/* 展开箭头 */
.arrow { margin-left:auto; transition:transform var(--transition-fast); color:var(--text-muted); flex-shrink:0; }
.arrow.rotated { transform:rotate(90deg); }
.sub-items { overflow:hidden; padding-bottom:4px; }
/* ===== 分隔线(菜单组之间) ===== */
.menu-group { padding:4px 0; }
.menu-group + .menu-group { border-top:1px solid var(--border-light); }
/* ===== 主体区域 ===== */
.main-area { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
/* ===== 顶栏 ===== */
.topbar {
display:flex; align-items:center; justify-content:space-between;
height:58px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 24px; flex-shrink:0;
}
.topbar-left { display:flex; align-items:center; }
.topbar-right { display:flex; align-items:center; }
.user-chip {
display:flex; align-items:center; gap:8px;
padding:5px 12px 5px 5px; border-radius:24px;
cursor:pointer; transition:all var(--transition-fast);
border:1px solid transparent;
}
.user-chip:hover { background:var(--bg-hover); border-color:var(--border-color); }
.user-name {
font-size:14px; font-weight:500; color:var(--text-secondary);
max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.user-chevron { color:var(--text-muted); flex-shrink:0; }
/* ===== 标签栏 ===== */
.tab-bar {
display:flex; align-items:center;
height:42px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 12px; flex-shrink:0; gap:4px;
}
.tab-list { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; height:100%; align-items:center; gap:2px; }
.tab-list::-webkit-scrollbar { height:2px; }
.tab-chip {
display:flex; align-items:center; gap:6px;
height:34px; padding:0 12px;
border-radius:var(--radius-sm);
cursor:pointer; font-size:13px;
color:var(--text-muted);
transition:all var(--transition-fast);
white-space:nowrap; flex-shrink:0;
}
.tab-chip:hover { background:var(--bg-hover); color:var(--text-secondary); }
.tab-chip.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:500; }
.tab-title { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
.tab-close { border-radius:3px; padding:1px; opacity:0; transition:all var(--transition-fast); }
.tab-chip:hover .tab-close { opacity:1; }
.tab-close:hover { background:var(--border-color); color:var(--text-primary); }
.tab-more { flex-shrink:0; }
.tab-more-btn {
display:flex; align-items:center; justify-content:center;
width:30px; height:30px; border-radius:var(--radius-sm);
cursor:pointer; color:var(--text-muted);
transition:all var(--transition-fast);
}
.tab-more-btn:hover { background:var(--bg-hover); color:var(--text-secondary); }
/* ===== 右键菜单 ===== */
.ctx-overlay { position:fixed; inset:0; z-index:9998; }
.ctx-menu {
position:fixed; z-index:9999;
background:var(--bg-surface); border:1px solid var(--border-color);
border-radius:var(--radius-md); box-shadow:var(--shadow-lg);
min-width:150px; padding:4px;
}
.ctx-item {
display:flex; align-items:center; gap:8px;
padding:9px 14px; font-size:14px; cursor:pointer;
color:var(--text-secondary); border-radius:var(--radius-sm);
transition:all var(--transition-fast);
}
.ctx-item:hover { background:var(--bg-hover); color:var(--text-primary); }
/* ===== 内容区 ===== */
.content { flex:1; overflow-y:auto; padding:24px; }
/* ===== 底部备案号 ===== */
.footer-icp {
text-align: center;
padding: 10px 0;
flex-shrink: 0;
border-top: 1px solid var(--border-light);
}
.footer-icp a {
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
}
.footer-icp a:hover {
color: var(--color-primary);
}
/* ===== 汉堡按钮 ===== */
.hamburger {
display: none; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: var(--radius-md);
cursor: pointer; color: var(--text-secondary);
margin-right: 8px; flex-shrink: 0;
}
.hamburger:hover { background: var(--bg-hover); color: var(--color-primary); }
/* ===== 侧边栏遮罩 ===== */
.sidebar-overlay {
display: none;
position: fixed; inset: 0; z-index: 99;
background: rgba(0,0,0,0.4);
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.hamburger { display: flex; }
.breadcrumb-desk { display: none; }
/* 侧边栏滑出 */
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
width: 260px !important;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.show { display: block; }
/* 主体全宽 */
.main-area { width: 100%; }
/* 顶栏 */
.topbar { padding: 0 12px; height: 50px; }
.user-chip { padding: 4px 8px 4px 4px; }
.user-name { display: none; }
/* 标签栏 */
.tab-bar { height: 36px; padding: 0 6px; }
.tab-chip { height: 28px; padding: 0 8px; font-size: 12px; }
.tab-title { max-width: 80px; }
/* 内容区 */
.content { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,324 @@
<!--
管理后台仪表盘页面
功能
1. 4 张统计卡片 系统总数用户总数今日登录当前在线
2. 30 天登录趋势折线图ECharts 动态加载
3. 微服务状态面板 各服务 UP/DOWN 状态及响应时间
4. JVM 运行信息折叠面板 CPU内存Java 版本操作系统
数据来源GET /zgapi/v1/admin/dashboard/stats聚合接口一次返回所有数据
图表渲染ECharts 动态 import仅此页面加载 echarts chunk
-->
<template>
<div class="dashboard">
<!-- 页面标题 -->
<div class="page-title">
<h2>概览</h2>
<p>网关平台运行状态一览</p>
</div>
<!-- ===== 统计卡片4 列网格移动端 2 /1 ===== -->
<div class="stat-grid">
<div class="stat-card" v-for="card in statCards" :key="card.label">
<div class="stat-icon-box" :style="{ background: card.bg, color: card.color }">
<el-icon :size="22"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">
<el-skeleton v-if="loading" animated style="width:40px;height:28px" />
<span v-else>{{ card.value }}</span>
</div>
</div>
</div>
</div>
<!-- 登录趋势图表全宽 -->
<div class="chart-section">
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<el-icon :size="16" color="var(--color-primary)"><DataLine /></el-icon>
<span>近30天登录趋势</span>
</div>
</div>
<div class="panel-body">
<el-skeleton v-if="chartLoading" animated :rows="8" />
<div ref="chartRef" class="chart-box" v-show="!chartLoading"></div>
<el-empty v-if="!chartLoading && !hasData" description="暂无登录数据" :image-size="80" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { Monitor, User, DataLine, Connection } from '@element-plus/icons-vue'
import request from '@/utils/request'
// ===== 响应式状态 =====
/** 仪表盘统计数据(含 serverInfo, services, loginTrend 等) */
const stats = reactive<any>({ serverInfo: {} })
/** ECharts 图表容器 DOM 引用 */
const chartRef = ref()
/** 是否有登录趋势数据(控制图表/空状态切换) */
const hasData = ref(false)
/** 统计卡片加载状态 */
const loading = ref(true)
/** 图表加载状态 */
const chartLoading = ref(true)
/** ECharts 实例引用,组件销毁时需手动 dispose */
let chartInstance: any = null
// ===== 统计卡片配置 =====
// 四张卡片分别展示系统数、用户数、今日登录、当前在线
const statCards = ref([
{ label: '系统总数', value: 0, icon: Monitor, bg: '#eef2ff', color: '#6366f1' },
{ label: '用户总数', value: 0, icon: User, bg: '#ecfdf5', color: '#10b981' },
{ label: '今日登录', value: 0, icon: DataLine, bg: '#fffbeb', color: '#f59e0b' },
{ label: '当前在线', value: 0, icon: Connection, bg: '#fef2f2', color: '#ef4444' },
])
// ===== 生命周期 =====
onMounted(async () => {
try {
// 调用仪表盘聚合 API一次返回所有统计数据
const res = await request.get('/zgapi/v1/admin/dashboard/stats')
Object.assign(stats, res.data)
// 将 API 返回值填入统计卡片
statCards.value[0].value = stats.systemCount || 0
statCards.value[1].value = stats.userCount || 0
statCards.value[2].value = stats.todayLoginCount || 0
statCards.value[3].value = stats.onlineCount || 0
loading.value = false
// 渲染图表:必须先关闭 chartLoading 使 chartRef div 出现在 DOM再初始化 ECharts
if (stats.loginTrend?.length) {
hasData.value = true
chartLoading.value = false
await nextTick()
renderChart()
} else {
chartLoading.value = false
}
} catch (e) {
console.error('仪表盘数据加载失败', e)
loading.value = false
chartLoading.value = false
}
// 响应式图表:窗口大小变化时自动重绘
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
// 清理事件监听和 ECharts 实例,防止内存泄漏
window.removeEventListener('resize', handleResize)
if (chartInstance) chartInstance.dispose()
})
// ===== 方法 =====
/** 窗口大小变化时自适应调整图表尺寸 */
function handleResize() {
chartInstance?.resize()
}
/**
* 渲染近30天登录趋势折线图
*
* 使用 ECharts 动态导入vite manualChunks 将 echarts 单独分包),
* 仅在仪表盘页面首次渲染时加载,不阻塞首屏。
*
* 图表配置:
* - 类型平滑折线图smooth: true
* - X 轴:日期(取自 stats.loginTrend[].date标签旋转 45 度防重叠
* - Y 轴:登录次数(整数刻度 minInterval: 1
* - 填充浅紫色渐变区域LinearGradient 从 12% 透明度到 0%
* - 悬浮:显示数据点圆点并放大
*/
async function renderChart() {
if (!chartRef.value) return
// 动态导入 ECharts避免非仪表盘页面加载该依赖
const echartsModule: any = await import('echarts')
// 动态导入是异步的,期间组件可能已卸载或 DOM 已变化,需再次检查
if (!chartRef.value) return
const echarts = echartsModule.default || echartsModule
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: '#e2e8f0',
textStyle: { color: '#1e293b', fontSize: 13 },
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
padding: [12, 16],
},
grid: { left: 40, right: 20, top: 16, bottom: 48 },
xAxis: {
type: 'category',
data: stats.loginTrend.map((d: any) => d.date),
axisLabel: { rotate: 45, fontSize: 11, color: '#94a3b8' },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
minInterval: 1, // Y 轴最小间隔为 1登录次数为整数
axisLabel: { fontSize: 11, color: '#94a3b8' },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
},
series: [{
data: stats.loginTrend.map((d: any) => d.count),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
showSymbol: false, // 默认隐藏数据点,保持图表清爽
emphasis: { showSymbol: true, symbolSize: 8 }, // 悬浮时显示并放大
lineStyle: { width: 2.5, color: '#6366f1' },
itemStyle: { color: '#6366f1' },
areaStyle: {
// 渐变填充:从上到下由浅紫渐变到完全透明
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
{ offset: 1, color: 'rgba(99,102,241,0.0)' },
]),
},
}],
})
}
</script>
<style scoped>
.dashboard {
/* 占满宽度 */
}
.page-title {
margin-bottom: 24px;
}
.page-title h2 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
margin-bottom: 4px;
}
.page-title p {
font-size: 14px;
color: var(--text-muted);
}
/* ===== 统计卡片 ===== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
transition: all var(--transition-base);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.stat-icon-box {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-body {
flex: 1;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
font-weight: 500;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.5px;
}
/* ===== 图表区域 ===== */
.chart-section {
margin-bottom: 0;
}
.chart-panel {
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.panel-body {
padding: 20px;
}
.chart-box {
height: 300px;
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.chart-box { height: 220px; }
.panel-body { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<div class="personal-page">
<div class="profile-grid">
<!-- 头像 + 基本信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><User /></el-icon>
<span>基本信息</span>
</div>
<div class="card-body">
<!-- 头像上传 -->
<div class="avatar-section">
<div class="avatar-wrapper" @click="triggerUpload">
<el-avatar :size="80" :src="form.avatar" class="profile-avatar">
{{ (form.realName || form.username || 'U').charAt(0) }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="avatar-info">
<div class="avatar-name">{{ form.realName || form.username }}</div>
<div class="avatar-role">{{ form.post || '普通用户' }}</div>
<el-button size="small" text type="primary" v-permission="'system:profile:avatar'" @click="triggerUpload">
<el-icon><Upload /></el-icon>更换头像
</el-button>
</div>
</div>
<el-form :model="form" label-width="80px" label-position="left" class="info-form">
<el-form-item label="用户名">
<el-input :model-value="form.username" disabled />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" v-permission="'system:profile:edit'" @click="saveProfile">
<el-icon><Check /></el-icon>保存信息
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码 + 账户信息 -->
<div class="right-cards">
<!-- 修改密码 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><Lock /></el-icon>
<span>修改密码</span>
</div>
<div class="card-body">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px" label-position="left">
<el-form-item label="原密码" prop="oldPwd">
<el-input v-model="pwdForm.oldPwd" type="password" placeholder="请输入原密码" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input v-model="pwdForm.newPwd" type="password" placeholder="至少6位" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPwd">
<el-input v-model="pwdForm.confirmPwd" type="password" placeholder="再次输入" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="changingPwd" v-permission="'system:profile:password'" @click="changePassword">
<el-icon><Key /></el-icon>修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 账户信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><InfoFilled /></el-icon>
<span>账户信息</span>
</div>
<div class="card-body">
<div class="meta-list">
<div class="meta-row">
<span class="meta-label">用户ID</span>
<code class="meta-value mono">{{ form.userId?.substring(0, 12) || '-' }}...</code>
</div>
<div class="meta-row">
<span class="meta-label">用户名</span>
<span class="meta-value">{{ form.username || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">注册时间</span>
<span class="meta-value">{{ formatDateTime(form.createdAt) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">最后登录</span>
<span class="meta-value">{{ formatDateTime(form.lastLoginTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Key, Check, InfoFilled, Camera, Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const fileInputRef = ref<HTMLInputElement>()
/** 文件对象存储服务地址 */
const FILE_SERVICE = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com'
/** 判断 URL 是否为文件服务地址(需要动态 Token */
function isFileServiceUrl(url: string): boolean {
return !!url && (url.includes('file.wg.zgitm.com') || url.includes(FILE_SERVICE.replace('https://', '').replace('http://', '')))
}
/** 从文件服务 URL 中提取 baseUrl去掉 ?token=xxx 参数) */
function stripToken(url: string): string {
if (!url) return url
return url.split('?')[0]
}
/** 为文件服务 URL 获取新 Token 并拼接完整地址 */
async function refreshAvatarToken(url: string): Promise<string> {
if (!url || !isFileServiceUrl(url)) return url
try {
const baseUrl = stripToken(url)
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const token = tokenRes.data?.token
if (!token) return url // 获取失败则返回原 URL
return `${baseUrl}?token=${token}`
} catch {
return url // 获取异常则返回原 URL头像可能加载失败但不阻塞页面
}
}
const form = reactive({
userId: '' as string | null,
username: '',
realName: '',
email: '',
phone: '',
avatar: '',
post: '',
createdAt: '',
lastLoginTime: '',
})
const saving = ref(false)
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/user/info')
const d = res.data
// 若头像地址来自文件服务,先刷新 Token 再赋值(避免先渲染旧 URL 导致一次失败请求)
let avatar = d.avatar ?? ''
if (isFileServiceUrl(avatar)) {
avatar = await refreshAvatarToken(avatar)
}
Object.assign(form, {
userId: d.userId ?? '',
username: d.username ?? '',
realName: d.realName ?? '',
email: d.email ?? '',
phone: d.phone ?? '',
avatar,
post: d.post ?? '',
createdAt: d.createdAt ?? '',
lastLoginTime: d.lastLoginTime ?? '',
})
setTimeout(() => pwdFormRef.value?.clearValidate(), 0)
} catch { /* ignore */ }
})
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleAvatarUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
// 1. 获取上传 Token一次一密上传完即消费
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const uploadToken = tokenRes.data?.token
if (!uploadToken) { ElMessage.error('获取上传Token失败'); return }
// 2. 上传到文件对象存储服务(消费 uploadToken
const axios = (await import('axios')).default
const fd = new FormData()
fd.append('file', file)
const uploadRes = await axios.post(`${FILE_SERVICE}/api/upload`, fd, {
timeout: 43200000,
headers: { Authorization: 'Bearer ' + uploadToken },
})
const fileId = uploadRes.data?.data?.fileId
if (!fileId) { ElMessage.error('上传返回异常'); return }
// 3. 获取新的下载 Token上传 Token 已消费,下载需新 Token
const baseUrl = `${FILE_SERVICE}/api/download/${fileId}`
const downloadTokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const downloadToken = downloadTokenRes.data?.token
if (!downloadToken) { ElMessage.error('获取下载Token失败'); return }
const avatarUrl = `${baseUrl}?token=${downloadToken}`
form.avatar = avatarUrl
// 4. 数据库只存不带 Token 的基础 URL页面加载时会动态获取新 Token
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
avatar: baseUrl,
})
await authStore.fetchUserInfo()
ElMessage.success('头像已更新')
} catch { ElMessage.error('上传失败') }
// 重置 input
if (fileInputRef.value) fileInputRef.value.value = ''
}
async function saveProfile() {
saving.value = true
try {
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
})
await authStore.fetchUserInfo()
ElMessage.success('个人信息已更新')
} catch { /* ignore */ } finally {
saving.value = false
}
}
// 密码
const pwdFormRef = ref<FormInstance>()
const changingPwd = ref(false)
const pwdForm = reactive({ oldPwd: '', newPwd: '', confirmPwd: '' })
const pwdRules: FormRules = {
oldPwd: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPwd: [
{ required: true, message: '请再次输入', trigger: 'blur' },
{ validator: (_r: any, v: string, cb: any) => v !== pwdForm.newPwd ? cb(new Error('两次密码不一致')) : cb(), trigger: 'blur' },
],
}
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
changingPwd.value = true
try {
await request.put('/zgapi/v1/auth/password/update', null, {
params: { oldPwd: pwdForm.oldPwd, newPwd: pwdForm.newPwd },
})
ElMessage.success('密码修改成功,即将重新登录')
pwdForm.oldPwd = ''; pwdForm.newPwd = ''; pwdForm.confirmPwd = ''
setTimeout(() => {
authStore.logout()
window.location.href = '/login'
}, 1500)
} catch { /* ignore */ } finally {
changingPwd.value = false
}
}
</script>
<style scoped>
.personal-page { max-width: 960px; }
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.right-cards { display: flex; flex-direction: column; gap: 20px; }
.profile-card {
background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); box-shadow: var(--shadow-xs); overflow: hidden;
}
.card-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 14px; color: var(--text-primary);
}
.card-body { padding: 24px 20px; }
/* 头像 */
.avatar-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border-light); }
.avatar-wrapper { position: relative; cursor: pointer; flex-shrink: 0; }
.profile-avatar { box-shadow: 0 4px 12px rgba(99,102,241,0.2); }
.avatar-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity var(--transition-fast); color: #fff;
}
.avatar-wrapper:hover .avatar-overlay { opacity: 1; }
.avatar-name { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.avatar-role { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
/* 表单 */
.info-form :deep(.el-form-item) { margin-bottom: 18px; }
.info-form :deep(.el-form-item:last-child) { margin-bottom: 0; padding-top: 4px; }
/* 元信息 */
.meta-list { display: flex; flex-direction: column; }
.meta-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--border-light); }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.meta-value { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.meta-value.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; gap: 12px; }
.personal-page { padding: 0; }
.avatar-section { flex-direction: column; text-align: center; }
.avatar-info { align-items: center; }
.card-body { padding: 16px; }
}
</style>

View File

@@ -0,0 +1,61 @@
<!--
系统配置页 文件对象存储配置
-->
<template>
<div class="config-page">
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><FolderOpened /></el-icon>
<span>文件对象存储配置</span>
</div>
</template>
<el-form ref="formRef" :model="form" label-width="140px" style="max-width:600px">
<el-form-item label="文件服务地址">
<el-input v-model="form.serviceUrl" placeholder="https://file.wg.zgitm.com" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" v-permission="'system:config:save'" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { FolderOpened, Check } from '@element-plus/icons-vue'
import request from '@/utils/request'
const saving = ref(false)
const form = reactive({ serviceUrl: '' })
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/config/list')
const list: any[] = res.data ?? []
const cfg = list.find((c: any) => c.configKey === 'file.service.url')
if (cfg) form.serviceUrl = cfg.configValue || ''
} catch { /* ignore */ }
})
async function handleSave() {
saving.value = true
try {
await request.post('/zgapi/v1/admin/config/save', {
configKey: 'file.service.url',
configValue: form.serviceUrl,
description: '外部文件对象存储服务地址',
})
ElMessage.success('保存成功')
} catch { /* ignore */ } finally { saving.value = false }
}
</script>
<style scoped>
.config-page { max-width:700px; }
.card-header-title { display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; color:var(--text-primary); }
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑字典' : '新增字典'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="字典类型" prop="dictType">
<el-select
v-model="form.dictType"
placeholder="请选择或输入"
filterable
allow-create
:disabled="isEdit"
style="width: 100%"
>
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入标签" />
</el-form-item>
<el-form-item label="值" prop="dictValue">
<el-input v-model="form.dictValue" placeholder="请输入值" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface DictTypeItem {
dictType: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 字典类型列表 */
const dictTypeList = ref<DictTypeItem[]>([])
/** 表单数据 */
const form = reactive({
dictType: '',
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
dictType: [{ required: true, message: '请选择或输入字典类型', trigger: 'blur' }],
dictLabel: [{ required: true, message: '请输入标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入值', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载字典类型列表 */
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/** 加载已有数据 */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式,可从 query 预填字典类型
const queryDictType = route.query.dictType
if (queryDictType) {
form.dictType = String(queryDictType)
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/dict/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
dictType: data.dictType ?? '',
dictLabel: data.dictLabel ?? '',
dictValue: data.dictValue ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载字典信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/dict/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/dict', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchDictTypeList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,331 @@
<!--
字典管理列表页
功能
1. 字典类型下拉筛选先通过 GET /zgapi/v1/admin/dict/type/list 获取类型列表
2. 标签文本前端过滤dictLabel 筛选在前端本地进行非后端过滤
3. 新增字典项路由跳转可携带当前选中的 dictType 作为默认值
4. 新增字典类型弹窗创建空字典类型后续再添加字典项
5. 删除字典项后同时刷新类型列表和数据列表
数据来源
- 字典类型列表GET /zgapi/v1/admin/dict/type/list
- 字典数据GET /zgapi/v1/admin/dict/type/:dictType返回该类型下全部条目
- 新增字典类型POST /zgapi/v1/admin/dict
- 删除字典项DELETE /zgapi/v1/admin/dict/:id
注意字典列表不分页一次返回该类型下所有条目dictLabel 筛选在前端进行
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="字典类型">
<el-select v-model="query.dictType" placeholder="全部" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="query.dictLabel" placeholder="请输入标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:dict:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增字典
</el-button>
<el-button v-permission="'system:dict:add'" @click="handleAddType">
<el-icon><FolderAdd /></el-icon>
新增字典类型
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="dictType" label="字典类型" min-width="140" />
<el-table-column prop="dictLabel" label="标签" min-width="120" />
<el-table-column prop="dictValue" label="值" min-width="120" />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="280" align="center">
<template #default="{ row }">
<el-button type="info" size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button type="primary" size="small" plain v-permission="'system:dict:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:dict:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无数据" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">字典类型</span><span class="fld-val">{{ row.dictType }}</span></div>
<div class="card-field"><span class="fld-label">标签</span><span class="fld-val">{{ row.dictLabel }}</span></div>
<div class="card-field"><span class="fld-label"></span><span class="fld-val">{{ row.dictValue }}</span></div>
<div class="card-field"><span class="fld-label">排序</span><span class="fld-val">{{ row.sortOrder }}</span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></span></div>
<div class="card-actions">
<el-button size="small" plain type="info" @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button size="small" plain type="primary" v-permission="'system:dict:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button size="small" plain type="danger" v-permission="'system:dict:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无数据,请选择字典类型或新增" :image-size="100" />
</div>
</el-card>
<!-- 查看字典详情弹窗 -->
<el-dialog v-model="detailVisible" title="字典详情" width="550px" destroy-on-close>
<el-descriptions v-if="detailRow" :column="2" border size="small">
<el-descriptions-item label="字典类型">{{ detailRow.dictType }}</el-descriptions-item>
<el-descriptions-item label="标签">{{ detailRow.dictLabel }}</el-descriptions-item>
<el-descriptions-item label="值">{{ detailRow.dictValue }}</el-descriptions-item>
<el-descriptions-item label="排序">{{ detailRow.sortOrder }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ detailRow.status === 1 ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 新增字典类型弹窗 -->
<el-dialog
v-model="typeDialogVisible"
title="新增字典类型"
width="450px"
:close-on-click-modal="false"
>
<el-form ref="typeFormRef" :model="typeForm" :rules="typeRules" label-width="100px">
<el-form-item label="字典类型" prop="dictType">
<el-input v-model="typeForm.dictType" placeholder="请输入字典类型编码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="typeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="typeSubmitLoading" @click="submitAddType">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, FolderAdd, Edit, Delete, MoreFilled, View } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 字典项类型 */
interface DictItem {
id?: string
dictType: string
dictLabel: string
dictValue: string
sortOrder: number
status: number
}
interface DictTypeItem {
dictType: string
}
// --- 查询 ---
const query = reactive({
dictType: '',
dictLabel: '',
})
const tableData = ref<DictItem[]>([])
const dictTypeList = ref<DictTypeItem[]>([])
const loading = ref(false)
/**
* 获取字典类型列表
* GET /zgapi/v1/admin/dict/type/list
* 返回所有已创建的字典类型编码,用于下拉选择器
*/
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/**
* 根据字典类型获取该类型下所有字典条目
* GET /zgapi/v1/admin/dict/type/:dictType
*
* 注意:
* - 如果未选择字典类型,直接清空表格(不发起请求)
* - dictLabel 筛选在前端进行includes 模糊匹配),后端返回全量数据
* - 接口不分页,返回该类型下所有条目
*/
async function fetchData() {
if (!query.dictType) {
tableData.value = []
return
}
loading.value = true
try {
const res = await request.get(`/zgapi/v1/admin/dict/type/${query.dictType}`)
let dataList = res.data ?? []
// 前端 dictLabel 筛选:使用 includes 模糊匹配
if (query.dictLabel) {
dataList = dataList.filter((item: DictItem) =>
item.dictLabel.includes(query.dictLabel)
)
}
tableData.value = dataList
} catch {
tableData.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
fetchData()
}
function handleReset() {
query.dictType = ''
query.dictLabel = ''
tableData.value = []
}
// --- 查看详情 ---
const detailVisible = ref(false)
const detailRow = ref<any>(null)
function showDetail(row: any) {
detailRow.value = row
detailVisible.value = true
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
let url = '/admin/dict/form'
if (query.dictType) {
url += '?dictType=' + query.dictType
}
router.push(url)
}
function handleEdit(row: DictItem) {
router.push('/admin/dict/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: DictItem) {
ElMessageBox.confirm(`确认删除字典"${row.dictLabel}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/dict/${row.id}`)
ElMessage.success('删除成功')
fetchDictTypeList()
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// --- 新增字典类型 ---
const typeDialogVisible = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref<FormInstance>()
const typeForm = reactive({
dictType: '',
})
const typeRules: FormRules = {
dictType: [{ required: true, message: '请输入字典类型编码', trigger: 'blur' }],
}
function handleAddType() {
typeForm.dictType = ''
setTimeout(() => typeFormRef.value?.clearValidate(), 0)
typeDialogVisible.value = true
}
async function submitAddType() {
const valid = await typeFormRef.value?.validate().catch(() => false)
if (!valid) return
typeSubmitLoading.value = true
try {
await request.post('/zgapi/v1/admin/dict', {
dictType: typeForm.dictType,
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
ElMessage.success('新增字典类型成功')
typeDialogVisible.value = false
fetchDictTypeList()
} catch {
// ignore
} finally {
typeSubmitLoading.value = false
}
}
// 初始化
fetchDictTypeList()
</script>
<style scoped>
.toolbar {
gap: 8px;
}
.actions-desk {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.actions-mob { display: none; }
@media (max-width: 768px) {
.toolbar { flex-direction: column; }
.toolbar .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="关键词">
<el-input v-model="query.keyword" placeholder="收件人或主题" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width:120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="openSendDialog" v-permission="'system:email:send'">
<el-icon><Promotion /></el-icon>发送邮件
</el-button>
</div>
<!-- 表格 + 分页 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="toAddress" label="收件人" min-width="180" show-overflow-tooltip />
<el-table-column prop="subject" label="主题" min-width="200" show-overflow-tooltip />
<el-table-column label="格式" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.contentType === 'html' ? '' : 'info'">
{{ row.contentType === 'html' ? 'HTML' : '文本' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 1 ? 'success' : 'danger'" effect="dark">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发送时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<el-button type="primary" size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>详情</el-button>
<el-popconfirm v-permission="'system:email:delete'" title="确定删除?" @confirm="del(row.id)">
<template #reference>
<el-button type="danger" size="small" plain><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无邮件发送记录" :image-size="100" />
</template>
</el-table>
<!-- 手机端卡片列表 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in list" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">收件人</span><span class="fld-val">{{ row.toAddress }}</span></div>
<div class="card-field"><span class="fld-label">主题</span><span class="fld-val">{{ row.subject }}</span></div>
<div class="card-field"><span class="fld-label">格式</span><span class="fld-val"><el-tag size="small" :type="row.contentType === 'html' ? '' : 'info'">{{ row.contentType === 'html' ? 'HTML' : '文本' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag size="small" :type="row.status === 1 ? 'success' : 'danger'" effect="dark">{{ row.status === 1 ? '成功' : '失败' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">发送时间</span><span class="fld-val">{{ formatDateTime(row.createdAt) }}</span></div>
<div class="card-actions">
<el-button size="small" type="primary" plain @click="showDetail(row)"><el-icon><View /></el-icon>详情</el-button>
<el-button size="small" type="danger" plain v-permission="'system:email:delete'" @click="confirmDel(row)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
</div>
<el-empty v-if="list.length === 0" description="暂无邮件发送记录" :image-size="100" />
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 发送弹窗 -->
<el-dialog v-model="sendVisible" title="发送邮件" width="620px" destroy-on-close class="send-dialog">
<el-form :model="sendForm" label-width="70px">
<el-form-item label="收件人" required>
<el-input v-model="sendForm.to" placeholder="user@example.com" />
</el-form-item>
<el-form-item label="主题" required>
<el-input v-model="sendForm.subject" placeholder="邮件主题" />
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="sendForm.contentType">
<el-radio value="html">HTML</el-radio>
<el-radio value="text">纯文本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="sendForm.content" type="textarea" rows="8" placeholder="邮件内容HTML格式支持标签" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sendVisible = false">取消</el-button>
<el-button type="primary" :loading="sending" @click="doSend">发送</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="邮件详情" width="660px" destroy-on-close class="detail-dialog">
<template v-if="detail">
<div class="detail-meta">
<span>收件人{{ detail.toAddress }}</span>
<span>时间{{ formatDateTime(detail.createdAt) }}</span>
<el-tag size="small" :type="detail.status === 1 ? 'success' : 'danger'" effect="dark">
{{ detail.status === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-subject">{{ detail.subject }}</div>
<div class="detail-body" v-if="detail.contentType === 'html'" v-html="detail.content" />
<pre class="detail-body" v-else>{{ detail.content }}</pre>
<div v-if="detail.errorMsg" class="detail-error">错误{{ detail.errorMsg }}</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Promotion, View, Delete } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
interface EmailItem {
id: string; toAddress: string; subject: string
content: string; contentType: string; status: number
errorMsg: string; createdAt: string
}
const loading = ref(false)
const list = ref<EmailItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const query = reactive({
keyword: '',
status: '' as number | '',
})
const sendVisible = ref(false)
const sending = ref(false)
const sendForm = reactive({ to: '', subject: '', content: '', contentType: 'html' })
const detailVisible = ref(false)
const detail = ref<EmailItem | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/page', {
page: page.value,
pageSize: pageSize.value,
keyword: query.keyword || undefined,
status: query.status !== '' ? query.status : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 拦截器已提示 */ } finally { loading.value = false }
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleReset() {
query.keyword = ''
query.status = ''
page.value = 1
fetchData()
}
function openSendDialog() {
sendForm.to = ''; sendForm.subject = ''; sendForm.content = ''; sendForm.contentType = 'html'
sendVisible.value = true
}
async function doSend() {
if (!sendForm.to || !sendForm.subject || !sendForm.content) {
ElMessage.warning('请填写完整信息'); return
}
sending.value = true
try {
await request.post('/zgapi/v1/admin/email/send', { ...sendForm })
ElMessage.success('邮件已发送')
sendVisible.value = false
fetchData()
} catch { /* 拦截器已提示 */ } finally { sending.value = false }
}
async function showDetail(row: EmailItem) {
try {
const res = await request.get(`/zgapi/v1/admin/email/${row.id}`)
detail.value = res.data
detailVisible.value = true
} catch { /* ignore */ }
}
async function del(id: string) {
try {
await request.delete(`/zgapi/v1/admin/email/${id}`)
ElMessage.success('已删除')
if (list.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
/** 移动端删除确认dropdown 内无法使用 el-popconfirm */
function confirmDel(row: any) {
ElMessageBox.confirm('确定删除该邮件记录?', '删除确认', { type: 'warning' })
.then(() => del(row.id))
.catch(() => {})
}
onMounted(() => fetchData())
</script>
<style scoped>
/* ===== 详情弹窗EmailList 特有) ===== */
.detail-meta {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color);
font-size: 13px; color: var(--text-muted);
}
.detail-subject {
font-size: 18px; font-weight: 600; color: var(--text-primary);
margin: 16px 0 12px; word-break: break-all;
}
.detail-body {
background: var(--bg-page); padding: 16px;
border-radius: var(--radius-md); font-size: 14px; line-height: 1.7;
max-height: 400px; overflow: auto; word-break: break-word;
}
.detail-body pre { white-space: pre-wrap; word-break: break-all; margin: 0; font-family: inherit; }
.detail-error {
margin-top: 12px; padding: 10px; color: var(--color-danger);
background: var(--color-danger-light); border-radius: 6px; font-size: 13px; word-break: break-all;
}
/* ===== 移动端 ===== */
@media (max-width: 768px) {
.detail-subject { font-size: 16px; }
.detail-body { max-height: 300px; font-size: 13px; padding: 12px; }
}
@media (max-width: 480px) {
.detail-meta { flex-direction: column; align-items: flex-start; gap: 6px; }
}
</style>

View File

@@ -0,0 +1,468 @@
<!--
文件对象储存 前端直调外部文件服务
上传: file.wg.zgitm.com/api/upload
下载: file.wg.zgitm.com/api/download/{fileId}
删除: file.wg.zgitm.com/api/delete/{fileId}
列表: gateway /zgapi/v1/admin/file/page查本地 sys_file
-->
<template>
<div class="page-wrapper">
<!-- 搜索 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="原文件名">
<el-input v-model="query.fileName" placeholder="请输入文件名" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="上传者">
<el-input v-model="query.uploadUserName" placeholder="请输入上传者" clearable />
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="query.fileType" placeholder="全部" clearable style="width:120px">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="全部" clearable style="width:160px">
<el-option v-for="s in systemList" :key="s.systemId" :label="s.systemCode + ' / ' + s.systemName" :value="s.systemId" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:file:upload'" @click="uploadVisible = true">
<el-icon><Upload /></el-icon>上传文件
</el-button>
</div>
<!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" title="上传文件到文件对象存储" width="560px" destroy-on-close @closed="resetUpload" class="upload-dialog">
<template #header>
<div class="dialog-header-custom">
<div class="header-icon-box"><el-icon :size="22"><UploadFilled /></el-icon></div>
<div>
<div class="header-title">上传文件</div>
<div class="header-sub">{{ uploadQueue.length ? uploadQueue.length + ' 个文件待上传' : '支持批量选择,逐个上传 — file.wg.zgitm.com' }}</div>
</div>
</div>
</template>
<div
class="drop-zone" :class="{ 'drag-over': dragOver, 'has-files': uploadQueue.length > 0 }"
@click="triggerUpload" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="onDrop"
>
<div v-if="uploadQueue.length === 0" class="drop-empty">
<div class="drop-icon-ring"><el-icon :size="36"><Cloudy /></el-icon></div>
<p class="drop-title">将文件拖拽到此处</p>
<p class="drop-hint">或点击此区域选择文件支持多选最大 100MB/</p>
</div>
<div v-else class="queue-list">
<div v-for="(f, i) in uploadQueue" :key="i" class="queue-card" :class="f._status">
<div class="q-icon" :class="f._status">
<el-icon v-if="f._status === 'done'" :size="22"><Check /></el-icon>
<el-icon v-else-if="f._status === 'fail'" :size="22"><Close /></el-icon>
<div v-else-if="f._status === 'uploading'" class="spinner"></div>
<el-icon v-else :size="22"><Document /></el-icon>
</div>
<div class="q-body">
<div class="q-name">{{ f.file.name }}</div>
<div class="q-meta"><span>{{ formatSize(f.file.size) }}</span></div>
<div v-if="f._status === 'uploading'" class="q-progress">
<el-progress :percentage="uploadPercent" :stroke-width="8" :show-text="true" color="#6366f1" />
<div class="q-speed">{{ uploadSpeed }}</div>
</div>
</div>
<el-button v-if="f._status === 'wait'" class="q-remove" circle size="small" text @click.stop="removeQueueItem(i)"><el-icon><Close /></el-icon></el-button>
<el-tag v-else-if="f._status === 'done'" type="success" size="small" effect="light" round>完成</el-tag>
<el-tag v-else-if="f._status === 'fail'" type="danger" size="small" effect="light" round>失败</el-tag>
</div>
</div>
<input ref="fileInputRef" type="file" multiple style="display:none" @change="onFileSelected" />
</div>
<div class="upload-footer">
<div class="footer-left">
<el-button @click="triggerUpload" :disabled="uploading" size="default"><el-icon><FolderAdd /></el-icon>添加文件</el-button>
<el-button v-if="uploadQueue.length > 0" @click="uploadQueue = []" :disabled="uploading" size="default" text>清空列表</el-button>
</div>
<el-button type="primary" :loading="uploading" size="large" :disabled="uploadQueue.length === 0 || allDone" @click="startUpload" class="btn-start">
<template v-if="!uploading"><el-icon><Upload /></el-icon>开始上传{{ uploadQueue.filter(f => f._status !== 'done').length }} </template>
<template v-else>{{ doneCount }}/{{ uploadQueue.length }}</template>
</el-button>
</div>
</el-dialog>
<!-- 表格 + 分页 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="fileList" border stripe v-loading="loading" style="width:100%">
<el-table-column prop="fileName" label="原文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="文件类型" width="90" align="center">
<template #default="{ row }">
<el-tag :type="fileTypeTag(row.fileType)" size="small" effect="dark">{{ fileTypeLabel(row.fileType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="文件大小" width="100" align="center">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column label="所属系统" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ sysName(row.systemId) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传者" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.uploadUserName || '-' }}</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center">
<template #default="{ row }">
<el-button size="small" :loading="downloadingId === row.id" @click="downloadFile(row)"><el-icon><Download /></el-icon>下载</el-button>
<el-button size="small" @click="copyDownloadUrl(row)"><el-icon><Link /></el-icon>复制链接</el-button>
<el-button size="small" type="danger" v-permission="'system:file:delete'" :loading="deletingId === row.id" @click="confirmDelete(row.id, row.fileName)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无文件" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片列表 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in fileList" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">文件名</span><span class="fld-val">{{ row.fileName }}</span></div>
<div class="card-field"><span class="fld-label">类型</span><span class="fld-val"><el-tag :type="fileTypeTag(row.fileType)" size="small" effect="dark">{{ fileTypeLabel(row.fileType) }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">大小</span><span class="fld-val">{{ formatSize(row.fileSize) }}</span></div>
<div class="card-field"><span class="fld-label">系统</span><span class="fld-val">{{ sysName(row.systemId) }}</span></div>
<div class="card-field"><span class="fld-label">上传者</span><span class="fld-val">{{ row.uploadUserName || '-' }}</span></div>
<div class="card-field"><span class="fld-label">时间</span><span class="fld-val">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="downloadFile(row)"><el-icon><Download /></el-icon>下载</el-button>
<el-button size="small" @click="copyDownloadUrl(row)"><el-icon><Link /></el-icon>复制链接</el-button>
<el-button size="small" type="danger" v-permission="'system:file:delete'" @click="confirmDelete(row.id, row.fileName)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
</div>
<el-empty v-if="fileList.length === 0" description="暂无文件" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" background
@current-change="fetchData" @size-change="fetchData" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Document, Delete, Upload, UploadFilled, Close, Download, Link, Cloudy, FolderAdd, Check } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
const FILE_SERVICE = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com'
const downloadingId = ref<string | null>(null)
const deletingId = ref<string | null>(null)
const loading = ref(false)
const fileList = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const query = reactive({ fileName: '', uploadUserName: '', fileType: '' as string, systemId: '' as string })
// 系统列表(下拉 + systemId→name 映射)
interface SysItem { systemId: string; systemCode: string; systemName: string }
const systemList = ref<SysItem[]>([])
function sysName(id: string | null): string {
if (!id) return '-'
const s = systemList.value.find(s => s.systemId === id)
return s ? s.systemCode + ' / ' + s.systemName : id.substring(0, 8) + '...'
}
async function fetchSystemList() {
try { const r = await request.get('/zgapi/v1/admin/system/list'); systemList.value = r.data || [] } catch { /* ignore */ }
}
function fileTypeTag(type: string) {
const m: Record<string,string> = { avatar:'success', icon:'warning', doc:'', other:'info' }
return m[type] || 'info'
}
function fileTypeLabel(type: string) {
const m: Record<string,string> = { avatar:'头像', icon:'图标', doc:'文档', other:'其他' }
return m[type] || type || '其他'
}
function formatSize(bytes: number | null) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
async function fetchData() {
loading.value = true
try {
const params: Record<string,unknown> = { page: page.value, pageSize: pageSize.value }
if (query.fileName) params.fileName = query.fileName
if (query.uploadUserName) params.uploadUserName = query.uploadUserName
if (query.fileType) params.fileType = query.fileType
if (query.systemId) params.systemId = query.systemId
const res = await request.post('/zgapi/v1/admin/file/page', params)
fileList.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* ignore */ } finally { loading.value = false }
}
function handleSearch() { page.value = 1; fetchData() }
function handleReset() { query.fileName = ''; query.uploadUserName = ''; query.fileType = ''; query.systemId = ''; page.value = 1; fetchData() }
/* 文件项(扩展 systemId */
interface FileItem { id: string; fileName: string; fileType: string; fileSize: number; mimeType: string; uploadUserName: string; systemId?: string; createdAt: string }
// ===== 上传(直调外部文件服务) =====
interface QueueItem { file: File; _status: 'wait' | 'uploading' | 'done' | 'fail' }
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadSpeed = ref('')
const dragOver = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const uploadQueue = ref<QueueItem[]>([])
let lastLoaded = 0, lastTime = 0
const doneCount = computed(() => uploadQueue.value.filter(f => f._status === 'done').length)
const allDone = computed(() => uploadQueue.value.length > 0 && uploadQueue.value.every(f => f._status === 'done' || f._status === 'fail'))
function resetUpload() {
if (uploading.value) return
uploading.value = false; uploadPercent.value = 0; uploadSpeed.value = ''
uploadQueue.value = []; dragOver.value = false
}
function triggerUpload() { fileInputRef.value?.click() }
function removeQueueItem(i: number) { uploadQueue.value.splice(i, 1) }
function addFiles(files: FileList | File[]) {
for (let i = 0; i < files.length; i++) uploadQueue.value.push({ file: files[i], _status: 'wait' })
}
function onFileSelected(e: Event) {
const files = (e.target as HTMLInputElement).files
if (files?.length) addFiles(files)
if (fileInputRef.value) fileInputRef.value.value = ''
}
function onDrop(e: DragEvent) {
dragOver.value = false
if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files)
}
/** Token 缓存 — 避开 mokee 网关全链路,直调文件服务更快 */
let cachedToken = ''
let cachedTokenPromise: Promise<string> | null = null
async function getFileServiceToken(): Promise<string> {
if (cachedToken) return cachedToken
if (cachedTokenPromise) return cachedTokenPromise
cachedTokenPromise = (async () => {
try {
const axios = (await import('axios')).default
const res = await axios.post(`${FILE_SERVICE}/api/token`, { systemCode: 'gateway' }, { timeout: 10000 })
return res.data?.data?.token || ''
} catch { return '' }
})()
const token = await cachedTokenPromise
cachedTokenPromise = null
if (token) cachedToken = token
return token
}
/** token 用后即清,下次重新获取 */
function clearCachedToken() { cachedToken = ''; cachedTokenPromise = null }
async function startUpload() {
if (uploadQueue.value.length === 0) return
uploading.value = true
const axios = (await import('axios')).default
for (let i = 0; i < uploadQueue.value.length; i++) {
const item = uploadQueue.value[i]
if (item._status === 'done') continue
item._status = 'uploading'; uploadPercent.value = 0; uploadSpeed.value = '准备上传...'
lastLoaded = 0; lastTime = Date.now()
try {
// 1. 获取 token
const token = await getFileServiceToken()
if (!token) { item._status = 'fail'; continue }
// 2. 上传
const fd = new FormData()
fd.append('file', item.file)
await axios.post(`${FILE_SERVICE}/api/upload`, fd, {
timeout: 43200000,
headers: { Authorization: 'Bearer ' + token },
onUploadProgress: (e: any) => {
if (e.total > 0) {
uploadPercent.value = Math.round((e.loaded / e.total) * 100)
const now = Date.now(); const elapsed = (now - lastTime) / 1000
if (elapsed >= 1) { uploadSpeed.value = formatSpeed((e.loaded - lastLoaded) / elapsed); lastLoaded = e.loaded; lastTime = now }
}
},
})
item._status = 'done'
clearCachedToken()
} catch { clearCachedToken(); item._status = 'fail' }
}
uploading.value = false
ElMessage.success(`上传完成: ${doneCount.value}/${uploadQueue.value.length} 个文件成功`)
page.value = 1; fetchData()
}
function formatSpeed(bps: number) {
if (bps < 1024) return bps.toFixed(0) + ' B/s'
if (bps < 1048576) return (bps / 1024).toFixed(1) + ' KB/s'
return (bps / 1048576).toFixed(1) + ' MB/s'
}
// ===== 下载(直接 URL 下载,浏览器原生处理保存对话框和进度) =====
async function downloadFile(item: FileItem) {
downloadingId.value = item.id
try {
const token = await getFileServiceToken()
if (!token) { ElMessage.error('获取下载Token失败'); return }
const url = `${FILE_SERVICE}/api/download/${item.id}?token=${encodeURIComponent(token)}`
// 用隐藏 iframe 触发下载,浏览器立即弹出保存对话框并在后台下载
// 对比 fetch→blob→click 方式:不需要等文件全部下载完才弹窗,大文件体验更好
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = url
document.body.appendChild(iframe)
// iframe 触发下载后即可移除(浏览器已接管下载流程)
setTimeout(() => document.body.removeChild(iframe), 3000)
clearCachedToken()
ElMessage.success('开始下载')
} catch { clearCachedToken(); ElMessage.error('下载失败') } finally {
downloadingId.value = null
}
}
// ===== 复制下载链接(自动获取 token 拼入 URL =====
async function copyDownloadUrl(item: FileItem) {
try {
const token = await getFileServiceToken()
if (!token) { ElMessage.error('获取下载Token失败'); return }
const url = `${FILE_SERVICE}/api/download/${item.id}?token=${token}`
await navigator.clipboard.writeText(url)
clearCachedToken()
ElMessage.success('已复制下载链接Token 12小时有效仅可使用一次')
} catch { clearCachedToken(); ElMessage.error('复制失败') }
}
async function confirmDelete(id: string, fileName: string) {
try {
await ElMessageBox.confirm(`确定删除「${fileName}」?删除后不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
center: true,
})
handleDelete(id)
} catch { /* 取消 */ }
}
// ===== 删除(带 loading =====
async function handleDelete(id: string) {
deletingId.value = id
try {
const token = await getFileServiceToken()
if (!token) { ElMessage.error('获取删除Token失败'); return }
const res = await fetch(`${FILE_SERVICE}/api/delete/${id}`, {
headers: { Authorization: 'Bearer ' + token }
})
if (!res.ok) { ElMessage.error('删除失败'); return }
clearCachedToken()
ElMessage.success('已删除')
if (fileList.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { clearCachedToken(); ElMessage.error('删除失败') } finally {
deletingId.value = null
}
}
function beforeUnload(e: BeforeUnloadEvent) {
if (uploading.value) { e.preventDefault(); e.returnValue = ''; return '' }
}
onMounted(() => { fetchSystemList(); fetchData(); window.addEventListener('beforeunload', beforeUnload) })
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
</script>
<style scoped>
/* 上传弹窗 */
.upload-dialog :deep(.el-dialog__header) { display:none; }
.upload-dialog :deep(.el-dialog__body) { padding:0; }
.dialog-header-custom { display:flex; align-items:center; gap:14px; padding:20px 28px 0; }
.header-icon-box { width:44px; height:44px; border-radius:var(--radius-lg); background:linear-gradient(135deg,#6366f1,#818cf8); display:flex; align-items:center; justify-content:center; color:#fff; flex-shrink:0; }
.header-title { font-size:17px; font-weight:700; color:var(--text-primary); }
.header-sub { font-size:12px; color:var(--text-muted); margin-top:2px; }
.drop-zone { margin:20px 28px; padding:24px; border:2px dashed var(--border-color); border-radius:var(--radius-xl); cursor:pointer; transition:all .25s; min-height:160px; max-height:360px; overflow-y:auto; }
.drop-zone:hover, .drop-zone.drag-over { border-color:var(--color-primary); background:linear-gradient(135deg,rgba(99,102,241,.03),rgba(129,140,248,.05)); }
.drop-zone.drag-over { border-style:solid; box-shadow:0 0 0 4px rgba(99,102,241,.1); }
.drop-empty { text-align:center; padding:20px 0; }
.drop-icon-ring { width:72px; height:72px; border-radius:50%; background:linear-gradient(135deg,#eef2ff,#e0e7ff); display:inline-flex; align-items:center; justify-content:center; margin-bottom:16px; color:var(--color-primary); }
.drop-title { font-size:16px; font-weight:600; color:var(--text-primary); margin-bottom:4px; }
.drop-hint { font-size:13px; color:var(--text-muted); }
.queue-list { display:flex; flex-direction:column; gap:10px; }
.queue-card { display:flex; align-items:center; gap:14px; padding:14px 16px; border-radius:var(--radius-lg); background:#fff; border:1px solid var(--border-color); transition:all .2s; }
.queue-card.done { border-color:#bbf7d0; background:#f0fdf4; }
.queue-card.fail { border-color:#fecaca; background:#fef2f2; }
.queue-card.uploading { border-color:#c7d2fe; background:#eef2ff; }
.q-icon { width:44px; height:44px; border-radius:var(--radius-md); display:flex; align-items:center; justify-content:center; flex-shrink:0; background:var(--bg-page); color:var(--text-muted); }
.q-icon.done { background:#dcfce7; color:#16a34a; }
.q-icon.fail { background:#fecaca; color:#dc2626; }
.spinner { width:20px; height:20px; border:2.5px solid #c7d2fe; border-top-color:var(--color-primary); border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.q-body { flex:1; min-width:0; }
.q-name { font-size:14px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.q-meta { font-size:12px; color:var(--text-muted); margin-top:2px; }
.q-progress { margin-top:8px; }
.q-speed { font-size:11px; color:var(--text-muted); text-align:right; margin-top:2px; }
.q-remove { flex-shrink:0; color:var(--text-muted); }
.q-remove:hover { color:var(--color-danger); background:#fef2f2; }
.upload-footer { display:flex; align-items:center; justify-content:space-between; padding:16px 28px 24px; border-top:1px solid var(--border-light); gap:12px; }
.footer-left { display:flex; align-items:center; gap:8px; }
.btn-start { min-width:160px; }
@media (max-width:768px) {
.upload-dialog :deep(.el-dialog) { width:100vw !important; max-width:100vw !important; margin:0 !important; border-radius:0; }
.drop-zone { margin:12px 16px; padding:16px; min-height:120px; max-height:240px; }
.dialog-header-custom { padding:16px 16px 0; }
.upload-footer { flex-direction:column; padding:12px 16px 20px; }
.footer-left { width:100%; flex-wrap:wrap; }
.btn-start { width:100%; }
}
</style>

View File

@@ -0,0 +1,190 @@
<!--
登录日志列表页
功能
- 按用户名登录状态成功/失败时间范围筛选登录记录
- 展示 systemName用户登录时选择的系统IP浏览器操作系统失败原因
- 分页查询POST /zgapi/v1/admin/log/login/page
OperationLogList 的差异
- 多了 systemName 用户登录时选择的系统名称
- 登录状态用 1/0而非字符串枚举
- 时间字段为 createdAt
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="登录状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="100" />
<el-table-column prop="realName" label="用户姓名" width="100" show-overflow-tooltip><template #default="{ row }">{{ row.realName || '-' }}</template></el-table-column>
<el-table-column prop="systemName" label="选择系统" width="140" show-overflow-tooltip><template #default="{ row }">{{ row.systemName || '-' }}</template></el-table-column>
<el-table-column prop="loginIp" label="登录IP" width="150" />
<el-table-column prop="createdAt" label="登录时间" width="180"><template #default="{ row }">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</template></el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '成功' : '失败' }}</el-tag></template></el-table-column>
<el-table-column prop="browser" label="浏览器" min-width="140" show-overflow-tooltip />
<el-table-column prop="os" label="操作系统" min-width="150" show-overflow-tooltip />
<el-table-column prop="failReason" label="失败原因" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.failReason || '-' }}</template></el-table-column>
<template #empty><el-empty description="暂无登录日志" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">用户名</span><span class="fld-val">{{ row.username }}</span></div>
<div class="card-field"><span class="fld-label">姓名</span><span class="fld-val">{{ row.realName || '-' }}</span></div>
<div class="card-field"><span class="fld-label">系统</span><span class="fld-val">{{ row.systemName || '-' }}</span></div>
<div class="card-field"><span class="fld-label">登录IP</span><span class="fld-val">{{ row.loginIp }}</span></div>
<div class="card-field"><span class="fld-label">登录时间</span><span class="fld-val">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '成功' : '失败' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">浏览器</span><span class="fld-val">{{ row.browser || '-' }}</span></div>
<div class="card-field"><span class="fld-label">操作系统</span><span class="fld-val">{{ row.os || '-' }}</span></div>
<div class="card-field"><span class="fld-label">失败原因</span><span class="fld-val">{{ row.failReason || '-' }}</span></div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无登录日志" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 登录日志条目类型 */
interface LoginLogItem {
username: string
systemName?: string // 用户登录时选择的系统名称(操作日志没有此字段)
loginIp: string
createdAt: string
status: number // 1=成功, 0=失败
browser: string
os: string
failReason?: string
}
// ===== 查询条件 =====
// status 使用 '' | 1 | 0'' 表示全部1 成功0 失败
const query = reactive({
page: 1,
pageSize: 10,
username: '',
status: '' as string | number,
dateRange: [] as string[],
})
const tableData = ref<LoginLogItem[]>([])
const total = ref(0)
const loading = ref(false)
/**
* 分页获取登录日志
*
* 请求方式POSTbody 传参,非 GET query string
* 后端接收的日期参数startTime / endTime由 dateRange 拆分为两个字段)
* status 为空字符串时不传(表示查询全部状态)
*/
async function fetchData() {
loading.value = true
try {
// 构建查询参数,只传有值的字段
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.status !== '') params.status = query.status
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
// POST 分页查询登录日志
const res = await request.post('/zgapi/v1/admin/log/login/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由 axios 拦截器统一处理
} finally {
loading.value = false
}
}
/** 搜索:重置到第一页并查询 */
function handleSearch() {
query.page = 1
fetchData()
}
/** 重置:清空所有条件,回到第一页 */
function handleReset() {
query.username = ''
query.status = ''
query.dateRange = []
query.page = 1
fetchData()
}
// 页面初始化时加载数据
fetchData()
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="page-wrapper">
<!-- 筛选栏 -->
<div class="toolbar">
<el-select v-model="filterConsumed" placeholder="消费状态" clearable style="width:140px" @change="fetchData">
<el-option label="未消费" :value="0" />
<el-option label="已消费" :value="1" />
</el-select>
<el-button @click="fetchData" style="margin-left:8px">刷新</el-button>
</div>
<!-- 表格 + 分页 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="systemCode" label="系统编码" min-width="100" />
<el-table-column label="Token" min-width="140" align="center">
<template #default="{ row }">
<span class="token-cell" @click="copyToken(row.tokenPreview)" :title="'点击复制完整Token'">
{{ tokenSuffix(row.tokenPreview) }}
</span>
</template>
</el-table-column>
<el-table-column label="消费状态" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.consumed === 1 ? 'success' : 'warning'">{{ row.consumed === 1 ? '已消费' : '未消费' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="获取时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.acquiredAt) }}</template>
</el-table-column>
<el-table-column label="消费时间" width="170">
<template #default="{ row }">{{ row.consumedAt ? formatDateTime(row.consumedAt) : '-' }}</template>
</el-table-column>
<el-table-column prop="consumeApi" label="消费接口" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.consumeApi || '-' }}</template>
</el-table-column>
<el-table-column prop="consumeIp" label="消费IP" min-width="110">
<template #default="{ row }">{{ row.consumeIp || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="showDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in list" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">系统编码</span><span class="fld-val">{{ row.systemCode }}</span></div>
<div class="card-field"><span class="fld-label">Token</span><span class="fld-val token-cell" style="font-size:12px" @click="copyToken(row.tokenPreview)">{{ tokenSuffix(row.tokenPreview) }}</span></div>
<div class="card-field"><span class="fld-label">消费状态</span><span class="fld-val"><el-tag size="small" :type="row.consumed === 1 ? 'success' : 'warning'">{{ row.consumed === 1 ? '已消费' : '未消费' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">获取时间</span><span class="fld-val">{{ formatDateTime(row.acquiredAt) }}</span></div>
<div class="card-field"><span class="fld-label">消费时间</span><span class="fld-val">{{ row.consumedAt ? formatDateTime(row.consumedAt) : '-' }}</span></div>
<div class="card-field"><span class="fld-label">消费接口</span><span class="fld-val" style="font-size:12px">{{ row.consumeApi || '-' }}</span></div>
<div class="card-field"><span class="fld-label">消费IP</span><span class="fld-val">{{ row.consumeIp || '-' }}</span></div>
<div class="card-actions">
<el-button size="small" type="primary" plain @click="showDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
</div>
<el-empty v-if="list.length === 0" description="暂无Token日志" :image-size="100" />
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- Token 详情弹窗 -->
<el-dialog v-model="detailVisible" title="Token日志详情" width="650px" destroy-on-close class="detail-dialog">
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="系统编码" :span="2">
<span class="desc-system-code">{{ detailRow.systemCode || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Token" :span="2">
<div class="desc-token-row">
<code class="desc-token">{{ detailRow.tokenPreview || '-' }}</code>
<el-button size="small" text type="primary" @click="copyText(detailRow.tokenPreview)">
<el-icon><DocumentCopy /></el-icon>复制
</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="消费状态">
<el-tag size="small" :type="detailRow.consumed === 1 ? 'success' : 'warning'">
{{ detailRow.consumed === 1 ? '已消费' : '未消费' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="获取时间">
{{ detailRow.acquiredAt ? formatDateTime(detailRow.acquiredAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="消费时间">
{{ detailRow.consumedAt ? formatDateTime(detailRow.consumedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="消费IP">
{{ detailRow.consumeIp || '-' }}
</el-descriptions-item>
<el-descriptions-item label="消费接口" :span="2">
<code class="desc-url">{{ detailRow.consumeApi || '-' }}</code>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { View, DocumentCopy } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 从 tokenPreview格式: 前20字符...后10字符中提取后缀展示 */
function tokenSuffix(preview: string): string {
if (!preview) return '-'
const idx = preview.lastIndexOf('...')
return idx >= 0 ? '...' + preview.substring(idx + 3) : preview
}
/** 复制完整 token 预览到剪贴板 */
async function copyToken(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('Token 已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
/** 复制文本到剪贴板(详情弹窗用) */
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'; ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('已复制到剪贴板')
}
}
interface TokenLog {
id: string; systemCode: string; tokenPreview: string
consumed: number; acquiredAt: string; consumedAt: string
consumeApi: string; consumeIp: string
}
const loading = ref(false)
const list = ref<TokenLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const filterConsumed = ref<number | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/token-log/page', {
page: page.value, pageSize: pageSize.value,
consumed: filterConsumed.value !== null ? filterConsumed.value : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch {} finally { loading.value = false }
}
// --- 详情弹窗 ---
const detailVisible = ref(false)
const detailRow = ref<TokenLog>({} as TokenLog)
function showDetail(row: any) {
detailRow.value = row
detailVisible.value = true
}
onMounted(() => fetchData())
</script>
<style scoped>
/* Token 点击复制OpenTokenLogList 特有) */
.token-cell {
cursor: pointer;
color: var(--color-primary);
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
user-select: all;
}
.token-cell:hover {
text-decoration: underline;
color: var(--color-primary-hover);
}
/* ===== Token 详情弹窗 ===== */
.detail-dialog :deep(.el-dialog__body) {
padding: 20px 24px;
}
.detail-desc {
margin-bottom: 0;
}
.desc-system-code {
font-weight: 600;
color: var(--color-primary);
}
.desc-token-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.desc-token {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
background: var(--bg-page);
padding: 4px 10px;
border-radius: 4px;
color: var(--text-secondary);
word-break: break-all;
flex: 1;
line-height: 1.6;
}
.desc-url {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
background: var(--bg-page);
padding: 2px 8px;
border-radius: 4px;
color: var(--text-secondary);
word-break: break-all;
}
/* ===== 移动端 ===== */
@media (max-width: 768px) {
.toolbar { flex-direction: column; gap: 10px; }
.toolbar .el-select { width: 100% !important; }
.detail-dialog :deep(.el-dialog) {
width: 95vw !important;
max-width: 95vw !important;
}
.detail-desc :deep(.el-descriptions__body .el-descriptions__table) {
font-size: 13px;
}
.desc-token {
font-size: 10px;
}
}
</style>

View File

@@ -0,0 +1,445 @@
<!--
操作日志列表页
功能
- 按用户名模块操作类型时间范围筛选操作记录
- 操作类型列使用彩色标签INSERT=绿, UPDATE=, DELETE=, SELECT=默认, OTHER=
- 请求方式列使用彩色标签GET=绿, POST=, PUT=, DELETE=
- 行展开查看请求体和错误详情JSON 格式化展示
- 响应状态码200 绿色其他红色
- 耗时>1000ms 橙色警告>500ms 默认<=500ms 绿色
数据来源POST /zgapi/v1/admin/log/operation/page
action 字段固定值INSERT, UPDATE, DELETE, SELECT, OTHER与后端 @OperationType 注解对齐
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="操作模块">
<el-input v-model="query.module" placeholder="请输入操作模块" clearable />
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="query.action" placeholder="全部" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="新增" value="INSERT" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
<el-option label="查询" value="SELECT" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column label="操作" width="80" align="center"><template #default="{ row }"><el-button size="small" type="primary" link @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button></template></el-table-column>
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="模块" width="140" show-overflow-tooltip />
<el-table-column prop="action" label="操作类型" width="100" align="center"><template #default="{ row }"><el-tag :type="actionTag(row.action)" size="small" effect="dark">{{ row.action }}</el-tag></template></el-table-column>
<el-table-column prop="requestUrl" label="请求地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="requestMethod" label="请求方式" width="100" align="center"><template #default="{ row }"><el-tag :type="methodTag(row.requestMethod)" size="small" effect="dark">{{ row.requestMethod }}</el-tag></template></el-table-column>
<el-table-column prop="responseStatus" label="响应状态码" width="110" align="center"><template #default="{ row }"><el-tag :type="row.responseStatus === 200 ? 'success' : 'danger'" size="small" effect="dark">{{ row.responseStatus ?? '-' }}</el-tag></template></el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" align="center"><template #default="{ row }"><el-tag :type="row.costTime > 1000 ? 'warning' : row.costTime > 500 ? '' : 'success'" size="small">{{ row.costTime ?? '-' }}</el-tag></template></el-table-column>
<el-table-column prop="createdAt" label="操作时间" width="180"><template #default="{ row }">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</template></el-table-column>
<template #empty><el-empty description="暂无操作日志" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">操作人</span><span class="fld-val">{{ row.username }}</span></div>
<div class="card-field"><span class="fld-label">模块</span><span class="fld-val">{{ row.module }}</span></div>
<div class="card-field"><span class="fld-label">操作类型</span><span class="fld-val"><el-tag :type="actionTag(row.action)" size="small" effect="dark">{{ row.action }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">请求地址</span><span class="fld-val" style="font-size:12px">{{ row.requestUrl }}</span></div>
<div class="card-field"><span class="fld-label">请求方式</span><span class="fld-val"><el-tag :type="methodTag(row.requestMethod)" size="small" effect="dark">{{ row.requestMethod }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">状态码</span><span class="fld-val"><el-tag :type="row.responseStatus === 200 ? 'success' : 'danger'" size="small" effect="dark">{{ row.responseStatus ?? '-' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">耗时</span><span class="fld-val"><el-tag :type="row.costTime > 1000 ? 'warning' : row.costTime > 500 ? '' : 'success'" size="small">{{ row.costTime ?? '-' }} ms</el-tag></span></div>
<div class="card-field"><span class="fld-label">操作时间</span><span class="fld-val">{{ row.createdAt ? formatDateTime(row.createdAt) : '-' }}</span></div>
<div class="card-actions"><el-button size="small" type="primary" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button></div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无操作日志" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 操作详情弹窗 -->
<el-dialog v-model="detailVisible" title="操作详情" width="750px" destroy-on-close class="detail-dialog">
<!-- 元信息描述列表 -->
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="操作人">
<span class="desc-operator">{{ detailRow.username || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="操作时间">
{{ detailRow.createdAt ? formatDateTime(detailRow.createdAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="请求接口" :span="2">
<el-tag size="small" :type="methodTag(detailRow.requestMethod)" effect="dark" style="margin-right:8px">
{{ detailRow.requestMethod || '-' }}
</el-tag>
<code class="desc-url">{{ detailRow.requestUrl || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="是否成功">
<el-tag :type="detailRow.responseStatus === 200 ? 'success' : 'danger'" effect="dark" size="small">
{{ detailRow.responseStatus === 200 ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="耗时">
<el-tag
:type="detailRow.costTime > 1000 ? 'warning' : detailRow.costTime > 500 ? '' : 'success'"
effect="dark" size="small"
>
{{ detailRow.costTime != null ? detailRow.costTime + ' ms' : '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="detailRow.errorMsg" label="错误信息" :span="2">
<span class="desc-error">{{ detailRow.errorMsg }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 报文折叠面板 -->
<el-collapse v-model="activePanels" class="detail-collapse" v-if="detailRow.requestBody || detailRow.responseBody">
<el-collapse-item v-if="detailRow.requestBody" name="request" title="传入报文">
<div class="code-header">
<span class="code-label">Request Body</span>
<el-button size="small" text @click="copyText(formatJson(detailRow.requestBody))">
<el-icon><DocumentCopy /></el-icon>复制
</el-button>
</div>
<pre class="code-block">{{ formatJson(detailRow.requestBody) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="detailRow.responseBody" name="response" title="返回报文">
<div class="code-header">
<span class="code-label">Response Body</span>
<el-button size="small" text @click="copyText(formatJson(detailRow.responseBody))">
<el-icon><DocumentCopy /></el-icon>复制
</el-button>
</div>
<pre class="code-block">{{ formatJson(detailRow.responseBody) }}</pre>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!detailRow.requestBody && !detailRow.responseBody && !detailRow.errorMsg" description="暂无报文详情" :image-size="80" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, View, DocumentCopy } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 操作日志类型 */
interface OperationLogItem {
username: string
module: string
action: string
requestUrl: string
requestMethod: string
requestBody?: string
responseBody?: string
responseStatus: number
costTime: number
createdAt: string
errorMsg?: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
module: '',
action: '',
dateRange: [] as string[],
})
const tableData = ref<OperationLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.module) params.module = query.module
if (query.action) params.action = query.action
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/operation/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.module = ''
query.action = ''
query.dateRange = []
query.page = 1
fetchData()
}
/**
* 操作类型 → el-tag 颜色映射
* 后端 @OperationLog 注解 action 字段使用中文值:新增/编辑/删除/分配菜单/上传/发送/绑定系统
* 同时兼容英文枚举值INSERT/UPDATE/DELETE/SELECT/OTHER
*/
function actionTag(type: string) {
const map: Record<string, string> = {
'新增': 'success', '编辑': 'warning', '删除': 'danger',
'分配菜单': 'primary', '绑定系统': 'primary', '上传': 'success', '发送': '',
INSERT: 'success', UPDATE: 'warning', DELETE: 'danger', SELECT: '', OTHER: 'info',
}
return map[type] || 'info'
}
/**
* HTTP 请求方式 → el-tag 颜色映射
* GET=success(绿), POST=primary(蓝), PUT=warning(橙), DELETE=danger(红), PATCH=info(灰)
*/
function methodTag(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
}
return map[method] || 'info'
}
/**
* 格式化 JSON 字符串为缩进展示
* 用于行展开区域显示请求体和错误信息
* 若非合法 JSON 则直接返回原字符串(避免 JSON.parse 抛异常)
*/
function formatJson(str: string) {
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
// --- 报文详情弹窗 ---
const detailVisible = ref(false)
const detailRow = ref<OperationLogItem>({} as OperationLogItem)
/** 折叠面板展开项:默认展开传入报文 */
const activePanels = ref<string[]>(['request'])
function showDetail(row: OperationLogItem) {
detailRow.value = row
activePanels.value = row.requestBody ? ['request'] : row.responseBody ? ['response'] : []
detailVisible.value = true
}
/** 复制文本到剪贴板 */
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
// 降级方案
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'; ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('已复制到剪贴板')
}
}
// 初始化
fetchData()
</script>
<style scoped>
/* ===== 操作详情弹窗 ===== */
.detail-dialog :deep(.el-dialog__body) {
padding: 20px 24px;
}
.detail-desc {
margin-bottom: 20px;
}
.desc-operator {
font-weight: 600;
color: var(--color-primary);
}
.desc-url {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
background: var(--bg-page);
padding: 2px 8px;
border-radius: 4px;
color: var(--text-secondary);
word-break: break-all;
}
.desc-error {
color: var(--color-danger);
font-size: 13px;
word-break: break-all;
}
/* ===== 报文折叠面板 ===== */
.detail-collapse {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.detail-collapse :deep(.el-collapse-item__header) {
padding: 0 16px;
height: 42px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-page);
border-bottom: 1px solid var(--border-light);
}
.detail-collapse :deep(.el-collapse-item__wrap) {
border-bottom: none;
}
.detail-collapse :deep(.el-collapse-item__content) {
padding: 0;
}
/* ===== 代码块 ===== */
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #1e293b;
border-bottom: 1px solid #334155;
}
.code-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.code-header .el-button {
color: #94a3b8 !important;
font-size: 12px;
}
.code-header .el-button:hover {
color: #e2e8f0 !important;
}
.code-block {
margin: 0;
padding: 16px;
background: #0f172a;
color: #e2e8f0;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 13px;
line-height: 1.7;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
/* 滚动条 — 深色主题 */
.code-block::-webkit-scrollbar { width: 5px; height: 5px; }
.code-block::-webkit-scrollbar-thumb { background: #475569; border-radius: 10px; }
.code-block::-webkit-scrollbar-track { background: #1e293b; }
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
/* 弹窗全屏 */
.detail-dialog :deep(.el-dialog) {
width: 95vw !important;
max-width: 95vw !important;
}
/* 描述列表堆叠 */
.detail-desc :deep(.el-descriptions__body .el-descriptions__table) {
font-size: 13px;
}
/* 代码块缩小字号 */
.code-block {
font-size: 11px;
padding: 12px;
max-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,366 @@
<!--
菜单表单页独立页面新增/编辑
MenuList.vue 中弹窗表单的区别
- MenuForm.vue独立路由页面通过 router.push 跳转URL 携带参数
- MenuList.vue同一页面内的 el-dialog 弹窗更多用于快捷操作
两者的表单逻辑各自独立无代码复用关系
功能
- 新增模式可从 query 参数读取 systemId parentId 预设值
- 编辑模式加载已有菜单数据先加载菜单树再填充其他字段
- 所属系统编辑时禁用菜单所属系统创建后不可更改
- 上级菜单使用 el-tree-select 从菜单树中选择留空为顶级菜单
- 菜单类型目录/菜单/按钮不同类型展示不同的表单字段
* 按钮(3)只需权限标识
* 菜单(2)路由路径必填+ 组件路径 + 图标 + 可选权限标识
* 目录(1)路由路径可选+ 图标
- 图标选择使用 IconPicker 组件
修复说明
- 系统列表端点统一为 GET /zgapi/v1/admin/system/list
- 菜单树加载时按 systemId 过滤
数据来源
- 系统列表GET /zgapi/v1/admin/system/list
- 菜单树GET /zgapi/v1/admin/menu/tree?systemId=
- 加载菜单数据GET /zgapi/v1/admin/menu/:id
- 新增POST /zgapi/v1/admin/menu
- 编辑PUT /zgapi/v1/admin/menu/:id
-->
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑菜单' : '新增菜单'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<!-- 所属系统新增时可切换编辑时展示不可修改 -->
<el-form-item label="所属系统" prop="systemId">
<el-select
v-model="form.systemId"
placeholder="请选择系统"
:disabled="isEdit"
style="width: 100%"
@change="onSystemChange"
>
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="上级菜单">
<el-tree-select
v-model="form.parentId"
:data="parentTreeData"
:props="{ value: 'id', label: 'menuName', children: 'children' }"
node-key="id"
placeholder="请选择上级菜单(留空为顶级)"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 按钮类型只需权限标识 -->
<template v-if="form.menuType === 3">
<el-divider content-position="left">
<span class="form-section-title">按钮权限</span>
</el-divider>
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<!-- 目录/菜单类型显示路由和图标 -->
<template v-else>
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="路由路径" :prop="form.menuType !== 1 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="权限标识" v-if="form.menuType === 2">
<el-input v-model="form.permission" placeholder="可选,按钮权限标识" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
interface SystemItem {
id: string
systemId: string
systemName: string
}
// 预定义可选图标列表(供 IconPicker 组件使用)
// 这些图标名称对应 @element-plus/icons-vue 中已注册的图标
// 实际渲染时由 getIconComponent 转换为组件对象
const iconList = [
'HomeFilled','Monitor','User','UserFilled','Document','Notebook','Tickets',
'Setting','Menu','List','Link','Collection','Avatar','DataLine','Connection',
'Switch','SwitchButton','ArrowRight','ArrowDown','Search','Plus','Edit',
'Delete','Check','Close','CircleClose','Remove','Expand','Fold',
'Key','Lock','InfoFilled','CircleCheckFilled',
]
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表 */
const systemList = ref<SystemItem[]>([])
/** 上级菜单树 */
const parentTreeData = ref<MenuNode[]>([])
/** 表单数据 */
const form = reactive({
systemId: undefined as string | undefined,
parentId: null as string | null,
menuName: '',
menuPath: '',
component: '',
icon: '',
menuType: 2,
permission: '',
sortOrder: 0,
status: 1,
})
/**
* 动态校验规则
*
* 根据菜单类型动态调整必填项:
* - 按钮(3):权限标识必填
* - 菜单(2):路由路径必填
* - 目录(1):无需额外必填项
* 使用 computed 确保表单类型变化时规则即时更新
*/
const rules = computed<FormRules>(() => ({
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3
? [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
: [],
menuPath: form.menuType === 2
? [{ required: true, message: '请输入路由路径', trigger: 'blur' }]
: [],
menuType: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
/** 加载系统列表 */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载菜单树(用于上级菜单选择) */
async function loadMenuTree(systemId: string) {
if (!systemId) {
parentTreeData.value = []
return
}
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId } })
parentTreeData.value = res.data ?? []
} catch {
parentTreeData.value = []
}
}
/** 切换系统时重新加载菜单树 */
function onSystemChange(systemId: string | undefined) {
form.parentId = null
if (systemId) {
loadMenuTree(systemId)
} else {
parentTreeData.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
// 新增模式,从 query 读取预设参数
isEdit.value = false
const querySystemId = route.query.systemId as string | undefined
const queryParentId = route.query.parentId as string | undefined
if (querySystemId) {
form.systemId = querySystemId
loadMenuTree(form.systemId)
}
if (queryParentId) {
form.parentId = queryParentId
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/menu/${editId.value}`)
const data = res.data
if (data) {
form.systemId = data.systemId
// 先加载菜单树,再赋值其他字段(确保 tree-select 数据就绪)
if (data.systemId) {
await loadMenuTree(data.systemId)
}
Object.assign(form, {
parentId: data.parentId ?? null,
menuName: data.menuName ?? '',
menuPath: data.menuPath ?? '',
component: data.component ?? '',
icon: data.icon ?? '',
menuType: data.menuType ?? 2,
permission: data.permission ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载菜单信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/menu/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/menu', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,309 @@
<!--
菜单管理页面树形表格 + 弹窗表单
MenuForm.vue独立页面表单的关系
- MenuList.vue 也包含菜单的新增/编辑表单通过 el-dialog 弹窗但这是为了方便在同一页面操作
- MenuForm.vue 是独立表单页供路由跳转使用
功能
1. 系统选择器下拉切换系统自动加载该系统下的菜单树
2. 树形表格使用 el-table tree-props 展示菜单层级
3. 展开/折叠全部通过 toggleRowExpansion 手动控制树节点展开状态
4. 图标列支持 URL 图片和 Element Plus 图标组件的动态渲染
5. 菜单类型标签目录(默认)菜单(绿色)按钮(橙色)
6. 操作列新增子级编辑删除
修复说明
- PUT URL 已修正为 /zgapi/v1/admin/menu/:id之前可能遗漏了 id
数据来源
- 系统列表GET /zgapi/v1/admin/system/list
- 菜单树GET /zgapi/v1/admin/menu/tree?systemId=
- 新增POST /zgapi/v1/admin/menu
- 编辑PUT /zgapi/v1/admin/menu/:id
- 删除DELETE /zgapi/v1/admin/menu/:id
-->
<template>
<div class="page-wrapper">
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><Menu /></el-icon>
<span>菜单管理</span>
</div>
</template>
<div class="menu-toolbar">
<span class="toolbar-label">选择系统</span>
<el-select v-model="currentSystemId" placeholder="请选择系统" style="width: 240px" @change="fetchMenuTree">
<el-option v-for="sys in systemList" :key="sys.systemId" :label="sys.systemName" :value="sys.systemId" />
</el-select>
<el-button type="primary" v-permission="'system:menu:add'" @click="openForm(null)" :disabled="!currentSystemId">
<el-icon><Plus /></el-icon>新增菜单
</el-button>
<el-button @click="toggleExpand">
<el-icon><Expand /></el-icon>
{{ isExpandAll ? '折叠全部' : '展开全部' }}
</el-button>
</div>
</el-card>
<el-card shadow="never">
<el-table ref="tableRef" :data="menuTreeData" border row-key="id"
:tree-props="{ children: 'children' }" :default-expand-all="true" v-loading="loading" style="width:100%">
<el-table-column prop="menuName" label="菜单名称" min-width="160" />
<el-table-column prop="icon" label="图标" width="80" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:18px;height:18px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="18"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="140" show-overflow-tooltip />
<el-table-column prop="component" label="组件" min-width="140" show-overflow-tooltip />
<el-table-column prop="menuType" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.menuType === 1 ? '' : row.menuType === 2 ? 'success' : 'warning'" effect="dark" size="small">
{{ menuTypeMap[row.menuType] ?? '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="permission" label="权限标识" min-width="120" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions actions-desk">
<el-button type="primary" size="small" plain v-permission="'system:menu:add'" @click="openForm(row)"><el-icon><Plus /></el-icon>新增子级</el-button>
<el-button type="primary" size="small" plain v-permission="'system:menu:edit'" @click="openForm(null, row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:menu:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
<el-dropdown trigger="click" class="actions-mob">
<el-button size="small" circle><el-icon><MoreFilled /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-permission="'system:menu:add'" @click="openForm(row)"><el-icon><Plus /></el-icon>新增子级</el-dropdown-item>
<el-dropdown-item v-permission="'system:menu:edit'" @click="openForm(null, row)"><el-icon><Edit /></el-icon>编辑</el-dropdown-item>
<el-dropdown-item v-permission="'system:menu:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 表单弹窗 -->
<el-dialog v-model="dlg.visible" :title="dlg.title" width="600px" :close-on-click-modal="false" @closed="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="上级菜单" v-if="!dlg.isEdit">
<el-tree-select v-model="form.parentId" :data="parentTree" :props="{ value:'id', label:'menuName', children:'children' }"
node-key="id" placeholder="留空为顶级菜单" clearable check-strictly style="width:100%" />
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="form.menuType === 3">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="路由路径" :prop="form.menuType === 2 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg.visible = false">取消</el-button>
<el-button type="primary" :loading="dlg.loading" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Expand, Menu, Edit, Delete, MoreFilled } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const menuTypeMap: Record<number, string> = { 1: '目录', 2: '菜单', 3: '按钮' }
interface MenuItem {
id?: string; parentId: string | null; menuName: string; menuPath: string; path?: string
component: string; icon: string; menuType: number; permission: string
sortOrder: number; status: number; children?: MenuItem[]
}
// 系统列表
const systemList = ref<any[]>([])
const currentSystemId = ref<string>()
const menuTreeData = ref<MenuItem[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const tableRef = ref()
const parentTree = ref<any[]>([])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemList.value = res.data ?? []
// 默认选 gateway 系统
const gw = systemList.value.find((s: any) => s.systemCode === 'gateway')
if (gw) { currentSystemId.value = gw.systemId; await fetchMenuTree() }
} catch { /* ignore */ }
})
async function fetchMenuTree() {
if (!currentSystemId.value) { menuTreeData.value = []; return }
loading.value = true
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: currentSystemId.value } })
menuTreeData.value = res.data ?? []
parentTree.value = res.data ?? []
} catch { menuTreeData.value = [] } finally { loading.value = false }
}
/**
* 切换全部树节点的展开/折叠状态
* 递归遍历 menuTreeData对每个有子节点的行调用 el-table 的 toggleRowExpansion 方法
*/
function toggleExpand() {
isExpandAll.value = !isExpandAll.value
const t = tableRef.value; if (!t) return
// 递归展开/折叠el-table 的 toggleRowExpansion 只影响直接子节点,需递归处理子孙
const toggle = (rows: any[]) => rows.forEach(r => { t.toggleRowExpansion(r, isExpandAll.value); if (r.children?.length) toggle(r.children) })
toggle(menuTreeData.value)
}
// 表单弹窗
const dlg = reactive({ visible: false, title: '', isEdit: false, loading: false })
const formRef = ref<FormInstance>()
const editId = ref<string>()
const form = reactive({ parentId: null as string | null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
const rules = computed<FormRules>(() => ({
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3 ? [{ required: true, message: '请输入权限标识', trigger: 'blur' }] : [],
menuPath: form.menuType === 2 ? [{ required: true, message: '请输入路由路径', trigger: 'blur' }] : [],
}))
/**
* 重置表单到默认值并清除校验状态
* 使用 setTimeout 延迟清除校验,确保 DOM 更新后再操作
*/
function resetForm() {
Object.assign(form, { parentId: null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
editId.value = undefined
// 清除校验状态,使用 setTimeout 避免弹窗打开瞬间显示红色错误提示
setTimeout(() => formRef.value?.clearValidate(), 0)
}
/**
* 打开菜单表单弹窗(新增子级 / 编辑)
*
* @param parent - 父菜单项,传 null 时有 parent 参数即为新增子级
* @param edit - 要编辑的菜单项,传入时进入编辑模式
*
* 注意:编辑模式下不显示上级菜单选择器,因为菜单的父子关系不可修改
*/
function openForm(parent: MenuItem | null, edit?: MenuItem) {
resetForm()
dlg.visible = true
if (edit) {
// 编辑模式:填充现有数据,上级菜单不可修改
dlg.title = '编辑菜单'
dlg.isEdit = true
editId.value = edit.id
Object.assign(form, {
menuName: edit.menuName, menuType: edit.menuType, menuPath: edit.path || '',
component: edit.component || '', icon: edit.icon || '',
permission: edit.permission || '', sortOrder: edit.sortOrder, status: edit.status,
})
} else {
dlg.title = parent ? '新增子菜单' : '新增菜单'
dlg.isEdit = false
if (parent) form.parentId = parent.id || null
}
}
/**
* 提交菜单表单(新增/编辑)
*
* 关键逻辑:
* - 始终携带当前选中的 systemId从系统选择器获取
* - 有 editId 时调用 PUT /:id 更新,否则调用 POST 新增
* - 成功后关闭弹窗并刷新菜单树
*/
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
dlg.loading = true
try {
const payload: any = { ...form, systemId: currentSystemId.value }
if (editId.value) {
// 编辑PUT + 路径 ID
await request.put(`/zgapi/v1/admin/menu/${editId.value}`, payload)
ElMessage.success('编辑成功')
} else {
// 新增POST
await request.post('/zgapi/v1/admin/menu', payload)
ElMessage.success('新增成功')
}
dlg.visible = false
fetchMenuTree() // 刷新菜单树
} catch { /* ignore */ } finally { dlg.loading = false }
}
function handleDelete(row: MenuItem) {
ElMessageBox.confirm(`确认删除菜单"${row.menuName}"吗?`, '删除确认', { type: 'warning' }).then(async () => {
try { await request.delete(`/zgapi/v1/admin/menu/${row.id}`); ElMessage.success('删除成功'); fetchMenuTree() } catch { /* ignore */ }
}).catch(() => {})
}
</script>
<style scoped>
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
.card-header-title { display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; color:var(--text-primary); }
.menu-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.toolbar-label { font-size:14px; font-weight:500; color:var(--text-secondary); }
.actions-desk { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; }
.actions-mob { display:none; }
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
.menu-toolbar { flex-direction: column; align-items: stretch; }
}
</style>

View File

@@ -0,0 +1,226 @@
<!--
角色表单页新增/编辑
功能
- 基本信息角色名称角色编码编辑时禁用所属系统状态
- 其他信息描述文本
- 所属系统下拉数据来源统一为 GET /zgapi/v1/admin/system/list
修复说明
- 系统列表端点已统一为 /system/listRoleList.vue fetchSystemList 也使用相同端点
- 编辑模式roleCode 字段禁用修改编码是角色的唯一标识
数据来源
- 系统列表GET /zgapi/v1/admin/system/list
- 加载角色数据GET /zgapi/v1/admin/role/:id
- 新增POST /zgapi/v1/admin/role
- 编辑PUT /zgapi/v1/admin/role/:id
-->
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑角色' : '新增角色'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="form.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码" prop="roleCode">
<el-input v-model="form.roleCode" placeholder="请输入角色编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface SystemItem {
id: string
systemId: string
systemName: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表(下拉用) */
const systemList = ref<SystemItem[]>([])
/** 表单数据 */
const form = reactive({
roleName: '',
roleCode: '',
systemId: undefined as string | undefined,
description: '',
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/**
* 加载系统列表(用于所属系统下拉框)
* GET /zgapi/v1/admin/system/list — 端点已统一
*/
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/role/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
roleName: data.roleName ?? '',
roleCode: data.roleCode ?? '',
systemId: data.systemId,
description: data.description ?? '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载角色信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/role/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/role', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,380 @@
<!--
角色管理列表页
功能
- 按角色名称所属系统筛选
- 操作列编辑删除分配菜单打开菜单树弹窗
- systemName 列显示角色所属系统名称由后端连表查询返回
分配菜单流程
1. 点击"分配菜单" 根据角色的 systemId 加载该系统的菜单树
2. 同时获取该角色已分配的菜单 ID 列表
3. 弹窗中预选已分配的菜单节点setCheckedKeys
4. 提交时将全部勾选节点含半选作为 menuIds 发送
数据来源
- 角色分页POST /zgapi/v1/admin/role/page
- 系统列表下拉GET /zgapi/v1/admin/system/list
- 菜单树GET /zgapi/v1/admin/menu/tree?systemId=
- 已分配菜单GET /zgapi/v1/admin/role/:id/menu
- 保存分配POST /zgapi/v1/admin/role/:id/menu
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="角色名称">
<el-input v-model="query.roleName" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="全部" clearable style="width: 180px">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:role:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="roleName" label="角色名称" width="140" />
<el-table-column prop="roleCode" label="角色编码" width="140" />
<el-table-column prop="systemName" label="所属系统" width="150" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="380" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions actions-desk">
<el-button size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button type="primary" size="small" plain v-permission="'system:role:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:role:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
<el-button type="success" size="small" plain v-permission="'system:role:assignMenu'" @click="handleAssignMenu(row)"><el-icon><Menu /></el-icon>分配菜单</el-button>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="暂无角色数据" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片列表 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">角色名称</span><span class="fld-val">{{ row.roleName }}</span></div>
<div class="card-field"><span class="fld-label">角色编码</span><span class="fld-val">{{ row.roleCode }}</span></div>
<div class="card-field"><span class="fld-label">所属系统</span><span class="fld-val">{{ row.systemName }}</span></div>
<div class="card-field"><span class="fld-label">描述</span><span class="fld-val">{{ row.description || '-' }}</span></div>
<div class="card-field">
<span class="fld-label">状态</span>
<span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></span>
</div>
<div class="card-actions">
<el-button size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button size="small" plain type="primary" v-permission="'system:role:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button size="small" plain type="danger" v-permission="'system:role:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
<el-button size="small" plain type="success" v-permission="'system:role:assignMenu'" @click="handleAssignMenu(row)"><el-icon><Menu /></el-icon>分配菜单</el-button>
</div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无角色数据" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 分配菜单弹窗 -->
<el-dialog
v-model="menuVisible"
title="分配菜单"
width="500px"
:close-on-click-modal="false"
>
<el-tree
ref="menuTreeRef"
:data="menuTreeData"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'menuName', children: 'children' }"
style="max-height: 400px; overflow-y: auto"
/>
<template #footer>
<el-button @click="menuVisible = false">取消</el-button>
<el-button type="primary" :loading="menuSubmitLoading" @click="submitAssignMenu">确定</el-button>
</template>
</el-dialog>
<!-- 查看详情弹窗 -->
<el-dialog v-model="detailVisible" title="角色详情" width="500px" destroy-on-close>
<el-descriptions v-if="detailRow" :column="2" border size="small">
<el-descriptions-item label="角色名称">{{ detailRow.roleName }}</el-descriptions-item>
<el-descriptions-item label="角色编码">{{ detailRow.roleCode }}</el-descriptions-item>
<el-descriptions-item label="所属系统">{{ detailRow.systemName }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ detailRow.status === 1 ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailRow.description || '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, Menu, MoreFilled, View } from '@element-plus/icons-vue'
import type { ElTree } from 'element-plus'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 角色类型 */
interface RoleItem {
id?: string
roleName: string
roleCode: string
systemId: string | undefined
systemName?: string
description: string
status: number
}
interface SystemItem {
id: string
systemId: string
systemName: string
}
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
roleName: '',
systemId: undefined as string | undefined,
})
const tableData = ref<RoleItem[]>([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRow = ref<any>(null)
function showDetail(row: any) {
detailRow.value = row
detailVisible.value = true
}
const systemList = ref<SystemItem[]>([])
/** 获取系统列表(用于"所属系统"下拉筛选和角色列表的 systemName 展示) */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemList.value = res.data ?? []
} catch {
// ignore
}
}
/**
* 分页获取角色列表
* POST /zgapi/v1/admin/role/page
*
* systemId 使用 != null 判断(而非 !== ''),因为 query.systemId 的类型是 string | undefined
* undefined 表示未选择(不传参),有值时才传
*/
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.roleName) params.roleName = query.roleName
// 使用 != null 判断:仅当 systemId 有明确值时才作为查询参数发送
if (query.systemId != null) params.systemId = query.systemId
const res = await request.post('/zgapi/v1/admin/role/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.roleName = ''
query.systemId = undefined
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/role/form')
}
function handleEdit(row: RoleItem) {
router.push('/admin/role/form/' + row.id)
}
function handleDelete(row: RoleItem) {
ElMessageBox.confirm(`确认删除角色"${row.roleName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/role/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// ===== 分配菜单 =====
const menuVisible = ref(false) // 菜单树弹窗可见性
const menuTreeData = ref<MenuNode[]>([]) // 菜单树数据
const menuSubmitLoading = ref(false) // 提交按钮加载状态
const menuRoleId = ref<string>() // 当前操作的角色 ID
const menuTreeRef = ref<InstanceType<typeof ElTree>>() // ElTree 组件引用
/**
* 打开"分配菜单"弹窗
*
* 流程:
* 1. 检查角色是否关联了系统systemId未关联则提示并退出
* 2. 并行请求菜单树GET /zgapi/v1/admin/menu/tree和已分配菜单 ID 列表GET /zgapi/v1/admin/role/:id/menu
* 3. 延时 50ms 等待 DOM 更新后,将已分配的菜单 ID 设置到树组件的勾选状态
*
* @param row - 当前行角色数据,需提供 id 和 systemId
*/
async function handleAssignMenu(row: RoleItem) {
menuRoleId.value = row.id
const sysId = row.systemId
if (!sysId) {
ElMessage.warning('该角色未关联系统,无法分配菜单')
return
}
menuVisible.value = true
menuTreeData.value = []
// 并行加载菜单树 + 已分配的菜单ID减少等待时间
try {
const [treeRes, checkedRes] = await Promise.all([
request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: sysId } }),
request.get(`/zgapi/v1/admin/role/${row.id}/menu`),
])
menuTreeData.value = treeRes.data ?? []
// 预选已分配的菜单
const checkedIds: string[] = checkedRes.data ?? []
// 等 DOM 更新后再设置勾选ElTree 的 setCheckedKeys 依赖节点已渲染)
await new Promise(r => setTimeout(r, 50))
menuTreeRef.value?.setCheckedKeys(checkedIds)
} catch {
ElMessage.error('加载菜单树失败')
}
}
/**
* 提交菜单分配
*
* check-strictly 模式下每个节点独立勾选,无父子联动:
* - 只勾"系统列表" → 仅查询权限,不授予新增/编辑/删除
* - 勾"系统列表"+"新增"+"编辑" → 查询+新增+编辑,不含删除
* - 提交的 menuIds 即为用户勾选的节点集合,不多不少
*/
async function submitAssignMenu() {
const menuIds = menuTreeRef.value?.getCheckedKeys() as string[] || []
menuSubmitLoading.value = true
try {
await request.post(`/zgapi/v1/admin/role/${menuRoleId.value}/menu`, menuIds)
ElMessage.success('分配菜单成功')
menuVisible.value = false
} catch {
// 错误由拦截器处理
} finally {
menuSubmitLoading.value = false
}
}
// 初始化
fetchData()
fetchSystemList()
</script>
<style scoped>
.actions-desk {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.actions-mob { display: none; }
@media (max-width: 768px) {
.toolbar .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,236 @@
<!--
API 接口表单页新增/编辑
功能
- 新增模式 URL query 参数 systemId 自动预设系统选择器
- 编辑模式 URL params id 加载已有接口数据并填充表单
- 表单提交后自动返回上一页
修复说明
- systemId query 参数正确获取ApiList 新增时通过 router.push 传递
- apiDesc 字段在编辑模式下正确填充
数据来源
- 系统下拉列表GET /zgapi/v1/admin/system/list
- 加载编辑数据GET /zgapi/v1/admin/system/api/:id
- 新增POST /zgapi/v1/admin/system/api
- 编辑PUT /zgapi/v1/admin/system/api/:id
-->
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑接口' : '新增接口'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="s in systemOptions"
:key="s.systemId"
:label="s.systemName"
:value="s.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径" prop="apiPath">
<el-input v-model="form.apiPath" placeholder="请输入接口路径,如 /zgapi/v1/admin/user" />
</el-form-item>
<el-form-item label="请求方式" prop="apiMethod">
<el-select v-model="form.apiMethod" placeholder="请选择请求方式" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="apiName">
<el-input v-model="form.apiName" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口描述" prop="apiDesc">
<el-input v-model="form.apiDesc" type="textarea" :rows="2" placeholder="请输入接口描述(可选)" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const systemOptions = ref<SystemOption[]>([])
/** 表单数据 */
const form = reactive({
systemId: null as string | null,
apiPath: '',
apiMethod: '',
apiName: '',
apiDesc: '',
status: 1,
})
/** 表单校验规则 */
const rules: FormRules = {
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
apiPath: [{ required: true, message: '请输入接口路径', trigger: 'blur' }],
apiMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
apiName: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
}
/** 加载系统列表 */
async function loadSystems() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemOptions.value = res.data ?? []
} catch {
// 错误由拦截器处理
}
}
/**
* 加载已有数据(编辑模式)或初始化新增模式
*
* 新增模式:从 route.query.systemId 读取预设系统 IDApiList 页跳转时携带)
* 编辑模式:通过 route.params.id 加载完整接口信息并填充表单
*
* 修复说明apiDesc 字段之前可能未从后端数据正确填充,现已包含在 Object.assign 中
*/
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式:从 URL query 参数获取预设的 systemId
// 该参数由 ApiList 页 handleAdd() 通过 router.push({ path, query }) 传递
const sysId = route.query.systemId as string | undefined
if (sysId) {
form.systemId = sysId
}
return
}
isEdit.value = true
editId.value = id
try {
// GET 单个接口详情
const res = await request.get(`/zgapi/v1/admin/system/api/${editId.value}`)
const data = res.data
if (data) {
// 用后端返回的数据填充表单,空值用默认值代替
Object.assign(form, {
systemId: data.systemId ?? null,
apiPath: data.apiPath ?? '',
apiMethod: data.apiMethod ?? '',
apiName: data.apiName ?? '',
apiDesc: data.apiDesc ?? '', // 接口描述 — 可空
status: data.status ?? 1,
})
// 延迟清除校验状态,避免刚填充数据时显示校验错误
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载接口信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/system/api/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system/api', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadSystems()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,314 @@
<!--
API 接口管理列表页
功能
- 按所属系统下拉选择接口路径请求方式筛选
- 请求方式列使用彩色标签GET=绿, POST=, PUT=, DELETE=
- 系统选择器默认值从 localStorage.currentSystemId 读取
- 新增时通过 query 参数携带当前 systemIdApiForm.vue 接收后自动选中
系统选择器加载流程
onMounted loadSystems() localStorage currentSystemId 设默认值 fetchData()
数据来源
- 系统下拉列表GET /zgapi/v1/admin/system/list
- 接口分页POST /zgapi/v1/admin/system/api/page
- 删除DELETE /zgapi/v1/admin/system/api/:id
路由
- 新增/admin/api/form?systemId=xxx
- 编辑/admin/api/form/:id
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="请选择系统" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="s in systemOptions"
:key="s.systemId"
:label="s.systemName"
:value="s.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径">
<el-input v-model="query.apiPath" placeholder="请输入接口路径" clearable />
</el-form-item>
<el-form-item label="请求方式">
<el-select v-model="query.apiMethod" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:api:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增接口
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="apiPath" label="接口路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="apiMethod" label="请求方式" width="100" align="center">
<template #default="{ row }"><el-tag :type="methodTagType(row.apiMethod)" effect="dark" size="small">{{ row.apiMethod }}</el-tag></template>
</el-table-column>
<el-table-column prop="apiName" label="接口名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="apiDesc" label="接口描述" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="{ row }">
<el-button type="info" size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button type="primary" size="small" plain v-permission="'system:api:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:api:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无接口数据" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">接口路径</span><span class="fld-val" style="font-size:12px">{{ row.apiPath }}</span></div>
<div class="card-field"><span class="fld-label">请求方式</span><span class="fld-val"><el-tag :type="methodTagType(row.apiMethod)" effect="dark" size="small">{{ row.apiMethod }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">接口名称</span><span class="fld-val">{{ row.apiName }}</span></div>
<div class="card-field"><span class="fld-label">描述</span><span class="fld-val">{{ row.apiDesc || '-' }}</span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></span></div>
<div class="card-actions">
<el-button size="small" plain type="info" @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button size="small" plain type="primary" v-permission="'system:api:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button size="small" plain type="danger" v-permission="'system:api:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无接口数据" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 查看接口详情 -->
<el-dialog v-model="detailVisible" title="接口详情" width="550px" destroy-on-close>
<el-descriptions v-if="detailRow" :column="2" border size="small">
<el-descriptions-item label="接口路径" :span="2">{{ detailRow.apiPath }}</el-descriptions-item>
<el-descriptions-item label="请求方式">
<el-tag :type="methodTagType(detailRow.apiMethod)" effect="dark" size="small">{{ detailRow.apiMethod }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ detailRow.status === 1 ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属系统" :span="2">{{ detailRow.systemName || '-' }}</el-descriptions-item>
<el-descriptions-item label="接口名称" :span="2">{{ detailRow.apiName || '-' }}</el-descriptions-item>
<el-descriptions-item label="接口描述" :span="2">{{ detailRow.apiDesc || '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, MoreFilled, View } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 接口类型 */
interface ApiItem {
id?: string
systemId: string
apiPath: string
apiMethod: string
apiName: string
apiDesc?: string
systemName?: string
status: number
}
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemId: undefined as string | undefined,
apiPath: '',
apiMethod: '',
})
const tableData = ref<ApiItem[]>([])
const total = ref(0)
const loading = ref(false)
const systemOptions = ref<SystemOption[]>([])
// ===== 加载系统列表(用于下拉框)=====
/**
* 获取系统列表并设置默认选中
*
* 从 localStorage 读取 currentSystemId用户在 SystemSelectView 选择系统时写入)
* 如果存在则自动设为筛选条件,实现"进入 API 管理时自动过滤当前系统"
*/
async function loadSystems() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
systemOptions.value = res.data ?? []
// 从 localStorage 取当前用户所在系统,设为默认选中
const sysId = localStorage.getItem('currentSystemId')
if (sysId) {
query.systemId = sysId
}
} catch {
// 错误由拦截器处理
}
}
// ===== 请求方式 → el-tag 颜色映射 =====
/** GET=success(绿), POST=warning(橙), PUT=primary(蓝), DELETE=danger(红) */
function methodTagType(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'warning',
PUT: 'primary',
DELETE: 'danger',
}
return map[method] || 'info'
}
// --- 数据加载 ---
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemId != null) params.systemId = query.systemId
if (query.apiPath) params.apiPath = query.apiPath
if (query.apiMethod) params.apiMethod = query.apiMethod
const res = await request.post('/zgapi/v1/admin/system/api/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemId = undefined
query.apiPath = ''
query.apiMethod = ''
query.page = 1
fetchData()
}
// --- 新增/编辑 ---
const router = useRouter()
function handleAdd() {
// 携带当前选中的 systemId
const query: Record<string, string> = {}
if (query.systemId != null) {
query.systemId = String(query.systemId)
}
router.push({ path: '/admin/api/form', query })
}
function handleEdit(row: ApiItem) {
router.push('/admin/api/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: ApiItem) {
ElMessageBox.confirm(`确认删除接口"${row.apiPath}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/api/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// 错误由拦截器处理
}
}).catch(() => {})
}
// --- 查看详情 ---
const detailVisible = ref(false)
const detailRow = ref<ApiItem | null>(null)
function showDetail(row: ApiItem) {
// 从系统列表中查找所属系统名称
const sys = systemOptions.value.find(s => s.systemId === row.systemId)
row.systemName = sys?.systemName
detailRow.value = row
detailVisible.value = true
}
// 初始化
onMounted(() => {
loadSystems().then(() => {
fetchData()
})
})
</script>
<style scoped>
.actions-desk {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.actions-mob { display: none; }
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑系统' : '新增系统'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="系统编码" prop="systemCode">
<el-input v-model="form.systemCode" placeholder="请输入系统编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="系统名称" prop="systemName">
<el-input v-model="form.systemName" placeholder="请输入系统名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="前端地址" prop="frontUrl">
<el-input v-model="form.frontUrl" placeholder="请输入前端地址" />
</el-form-item>
<el-form-item label="后端地址">
<el-input v-model="form.backendUrl" placeholder="请输入后端地址" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 表单数据 */
const form = reactive({
systemCode: '',
systemName: '',
frontUrl: '',
backendUrl: '',
icon: '',
sortOrder: 0,
status: 1,
description: '',
})
/** 表单校验规则 */
const rules: FormRules = {
systemCode: [{ required: true, message: '请输入系统编码', trigger: 'blur' }],
systemName: [{ required: true, message: '请输入系统名称', trigger: 'blur' }],
frontUrl: [{ required: true, message: '请输入前端地址', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/system/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
systemCode: data.systemCode ?? '',
systemName: data.systemName ?? '',
frontUrl: data.frontUrl ?? '',
backendUrl: data.backendUrl ?? '',
icon: data.icon ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
description: data.description ?? '',
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载系统信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put('/zgapi/v1/admin/system', { ...form, id: editId.value })
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,272 @@
<!--
系统管理列表页
功能
- 按系统编码系统名称状态筛选
- 图标列支持 URL 图片和 Element Plus 图标组件通过 getIconComponent 动态解析
- 操作列编辑删除文档打开本系统的接入文档页 /docs/:docToken
- 新增按钮使用 v-permission 指令控制可见性 system:system:add 权限
路由
- 新增/admin/system/form
- 编辑/admin/system/form/:id
- 文档新标签页打开 /docs/:docToken公开文档无需登录
数据来源
- 分页列表POST /zgapi/v1/admin/system/page
- 删除DELETE /zgapi/v1/admin/system/:id
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="系统编码">
<el-input v-model="query.systemCode" placeholder="请输入系统编码" clearable />
</el-form-item>
<el-form-item label="系统名称">
<el-input v-model="query.systemName" placeholder="请输入系统名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:system:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增系统
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="systemCode" label="系统编码" width="140" />
<el-table-column prop="systemName" label="系统名称" width="160" />
<el-table-column prop="icon" label="图标" width="70" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:20px;height:20px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="20"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="frontUrl" label="前端地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="backendUrl" label="后端真实地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="360" align="center">
<template #default="{ row }">
<el-button size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button type="primary" size="small" plain v-permission="'system:system:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:system:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
<el-button type="success" size="small" plain @click="openDoc(row)"><el-icon><Document /></el-icon>文档</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无系统数据" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">系统编码</span><span class="fld-val">{{ row.systemCode }}</span></div>
<div class="card-field"><span class="fld-label">系统名称</span><span class="fld-val">{{ row.systemName }}</span></div>
<div class="card-field"><span class="fld-label">前端地址</span><span class="fld-val" style="font-size:12px">{{ row.frontUrl || '-' }}</span></div>
<div class="card-field"><span class="fld-label">后端地址</span><span class="fld-val" style="font-size:12px">{{ row.backendUrl || '-' }}</span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></span></div>
<div class="card-actions">
<el-button size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button size="small" plain type="primary" v-permission="'system:system:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button size="small" plain type="danger" v-permission="'system:system:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
<el-button size="small" plain type="success" @click="openDoc(row)"><el-icon><Document /></el-icon>文档</el-button>
</div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无系统数据" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 查看详情弹窗 -->
<el-dialog v-model="detailVisible" title="系统详情" width="600px" destroy-on-close>
<el-descriptions v-if="detailRow" :column="2" border size="small">
<el-descriptions-item label="系统编码">{{ detailRow.systemCode }}</el-descriptions-item>
<el-descriptions-item label="系统名称">{{ detailRow.systemName }}</el-descriptions-item>
<el-descriptions-item label="前端地址" :span="2">{{ detailRow.frontUrl || '-' }}</el-descriptions-item>
<el-descriptions-item label="后端地址" :span="2">{{ detailRow.backendUrl || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关地址" :span="2">{{ detailRow.gatewayUrl || '-' }}</el-descriptions-item>
<el-descriptions-item label="文档Token">{{ detailRow.docToken || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ detailRow.status === 1 ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailRow.description || '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, Document, MoreFilled, View } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 系统类型 */
interface SystemItem {
id?: string
systemCode: string
systemName: string
frontUrl: string
backendUrl: string
icon: string
description: string
docToken: string
status: number
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemCode: '',
systemName: '',
status: '' as string | number,
})
const tableData = ref<SystemItem[]>([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRow = ref<any>(null)
function showDetail(row: any) {
detailRow.value = row
detailVisible.value = true
}
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemCode) params.systemCode = query.systemCode
if (query.systemName) params.systemName = query.systemName
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/system/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemCode = ''
query.systemName = ''
query.status = ''
query.page = 1
fetchData()
}
// ===== 新增/编辑(路由跳转到独立表单页)=====
const router = useRouter()
/** 跳转到系统新增页 */
function handleAdd() {
router.push('/admin/system/form')
}
/**
* 打开系统接入文档(新标签页)
* 文档路由为 /docs/:docToken公开访问无需登录
*/
function openDoc(row: SystemItem) {
window.open('/docs/' + row.docToken, '_blank')
}
/** 跳转到系统编辑页,携带系统 ID */
function handleEdit(row: SystemItem) {
router.push('/admin/system/form/' + row.id)
}
// ===== 删除:确认弹窗后调用 DELETE 接口 =====
function handleDelete(row: SystemItem) {
ElMessageBox.confirm(`确认删除系统"${row.systemName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/${row.id}`)
ElMessage.success('删除成功')
fetchData() // 删除成功后刷新列表
} catch {
// 错误由 axios 响应拦截器统一处理
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.actions-desk {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.actions-mob { display: none; }
@media (max-width: 768px) {
.toolbar { margin-bottom: 4px; }
.toolbar .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,537 @@
<!--
用户表单页新增/编辑
功能
- 新增模式基本信息 + 密码 + 系统绑定 + 角色分配提交后一并保存绑定和角色
- 编辑模式基本信息用户名禁用+ 管理员重置密码 super_admin 可见+ 系统绑定 + 角色分配
系统绑定流程
1. 加载所有系统列表GET /zgapi/v1/admin/system/list
2. 编辑模式加载用户已绑定的系统 IDGET /zgapi/v1/admin/user/:id/system
3. 编辑模式勾选/取消系统时自动保存新增模式仅在本地记录提交时保存
角色分配流程
1. 先选择系统 加载该系统下的角色列表POST /zgapi/v1/admin/role/page
2. 编辑模式加载用户在该系统下已分配的角色GET /zgapi/v1/admin/user/:id/role/:systemId
3. 勾选角色点击"保存"时一并提交
数据来源
- 系统列表GET /zgapi/v1/admin/system/list
- 加载用户数据GET /zgapi/v1/admin/user/:id
- 新增用户POST /zgapi/v1/admin/user返回 {id} 用于后续绑定
- 编辑用户PUT /zgapi/v1/admin/user
- 管理员重置密码PUT /zgapi/v1/admin/user/password super_adminbody: {id, password}
- 用户系统绑定GET/POST /zgapi/v1/admin/user/:id/system
- 角色分页POST /zgapi/v1/admin/role/page
- 用户角色GET /zgapi/v1/admin/user/:id/role/:systemId
- 保存角色POST /zgapi/v1/admin/user/:id/role
-->
<template>
<div class="form-page">
<el-page-header @back="handleBack" :title="isEdit ? '编辑用户' : '新增用户'" />
<el-divider />
<el-card shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left"><span class="section-label">基本信息</span></el-divider>
<!-- 头像上传仅编辑自己时可见 -->
<div v-if="isSelf" class="avatar-section">
<div class="avatar-wrapper" @click="triggerUpload">
<el-avatar :size="72" :src="avatarUrl" class="user-avatar">
{{ (form.realName || form.username || 'U').charAt(0) }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="18"><Camera /></el-icon>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="avatar-info">
<span class="avatar-hint">点击头像上传支持 JPG/PNG</span>
<el-button v-if="avatarUrl" size="small" text type="danger" @click="removeAvatar">移除头像</el-button>
</div>
</div>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
</el-col>
</el-row>
<!-- 管理员重置密码仅编辑模式 + super_admin 可见 -->
<el-row v-if="isEdit && authStore.isAdmin" :gutter="20">
<el-col :span="12">
<el-form-item label="重置密码">
<el-input v-model="form.password" type="password" placeholder="留空则不修改密码" show-password />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- ===== 系统 & 角色绑定新增和编辑模式均可见 ===== -->
<el-divider content-position="left"><span class="section-label">系统绑定</span></el-divider>
<el-form-item label="绑定系统">
<el-checkbox-group v-model="bindSystemIds" @change="onSystemChange">
<el-checkbox
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemId"
>
{{ sys.systemName }}
<el-tag size="small" type="info" style="margin-left:4px">{{ sys.systemCode }}</el-tag>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-divider content-position="left"><span class="section-label">角色分配</span></el-divider>
<p class="hint-text">先选择系统再为该用户分配该系统下的角色</p>
<el-form-item label="选择系统">
<el-select
v-model="roleSystemId"
placeholder="请选择系统"
clearable
style="width:280px"
@change="onRoleSystemChange"
>
<el-option
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemName + ' (' + sys.systemCode + ')'"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item v-if="roleSystemId" label="分配角色">
<el-checkbox-group v-model="assignRoleIds">
<el-checkbox
v-for="r in currentRoleList"
:key="r.id"
:label="r.id"
>
{{ r.roleName }}
<span style="color:var(--text-muted);font-size:12px;margin-left:4px">{{ r.roleCode }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="currentRoleList.length === 0" description="该系统下暂无角色" :image-size="60" />
</el-form-item>
<!-- ===== 按钮 ===== -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Camera } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
realName: '',
email: '',
phone: '',
post: '',
password: '',
status: 1,
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
realName: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: isEdit.value ? [] : [{ required: true, message: '请输入密码', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
// ===== 头像上传(仅编辑自己时可用) =====
const FILE_SERVICE = import.meta.env.VITE_FILE_SERVICE_URL || 'https://file.wg.zgitm.com'
/** 判断 URL 是否为文件服务地址(需要动态 Token */
function isFileServiceUrl(url: string): boolean {
return !!url && (url.includes('file.wg.zgitm.com') || url.includes(FILE_SERVICE.replace('https://', '').replace('http://', '')))
}
/** 为文件服务 URL 获取新下载 Token */
async function refreshAvatarToken(url: string): Promise<string> {
if (!url || !isFileServiceUrl(url)) return url
try {
const baseUrl = url.split('?')[0]
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const token = tokenRes.data?.token
return token ? `${baseUrl}?token=${token}` : url
} catch {
return url
}
}
/** 是否编辑自己的账号 — 只有本人才能修改头像 */
const isSelf = computed(() => {
return isEdit.value && authStore.userInfo?.userId === editId.value
})
const fileInputRef = ref<HTMLInputElement>()
const avatarUrl = ref<string>('')
/** 触发文件选择对话框 */
function triggerUpload() {
fileInputRef.value?.click()
}
/** 上传头像到文件服务,返回不带 Token 的基础 URL */
async function handleAvatarUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
// 1. 获取上传 Token
const tokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const uploadToken = tokenRes.data?.token
if (!uploadToken) { ElMessage.error('获取上传Token失败'); return }
// 2. 上传文件
const axios = (await import('axios')).default
const fd = new FormData()
fd.append('file', file)
const uploadRes = await axios.post(`${FILE_SERVICE}/api/upload`, fd, {
timeout: 43200000,
headers: { Authorization: 'Bearer ' + uploadToken },
})
const fileId = uploadRes.data?.data?.fileId
if (!fileId) { ElMessage.error('上传返回异常'); return }
// 3. 获取下载 Token 拼接预览 URL
const baseUrl = `${FILE_SERVICE}/api/download/${fileId}`
const downloadTokenRes = await request.post('/zgapi/v1/open/token', { systemCode: 'gateway' })
const downloadToken = downloadTokenRes.data?.token
if (downloadToken) {
avatarUrl.value = `${baseUrl}?token=${downloadToken}`
}
// 4. 保存到用户记录(存不带 Token 的基础 URL
await request.put('/zgapi/v1/admin/user', { id: editId.value, avatar: baseUrl })
// 如果是当前登录用户,刷新全局用户信息
await authStore.fetchUserInfo()
ElMessage.success('头像已更新')
} catch {
ElMessage.error('上传失败')
}
if (fileInputRef.value) fileInputRef.value.value = ''
}
/** 移除头像 */
async function removeAvatar() {
avatarUrl.value = ''
try {
await request.put('/zgapi/v1/admin/user', { id: editId.value, avatar: '' })
await authStore.fetchUserInfo()
ElMessage.success('头像已移除')
} catch { /* ignore */ }
}
// ===== 系统绑定 =====
const allSystems = ref<any[]>([])
const bindSystemIds = ref<string[]>([])
/**
* 获取所有系统列表(用于系统绑定复选框)
* GET /zgapi/v1/admin/system/list
* 修复点:端点已统一为 /system/list之前可能使用过其他路径
*/
async function fetchAllSystems() {
try {
const res = await request.get('/zgapi/v1/admin/system/list')
allSystems.value = res.data ?? []
} catch { /* ignore */ }
}
/** 获取用户已绑定的系统 ID 列表 */
async function fetchUserSystems() {
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/system`)
bindSystemIds.value = res.data ?? [] // 返回的是 systemId 数组
} catch { /* ignore */ }
}
/**
* 系统勾选变化
* 编辑模式下立即自动保存绑定关系;新增模式下仅在本地记录,提交时一并保存
*/
async function onSystemChange(_val: string[]) {
if (isEdit.value) await saveSystems()
}
/** 保存用户的系统绑定关系POST body 为 systemId 数组) */
async function saveSystems() {
if (!editId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/system`, bindSystemIds.value)
} catch { /* ignore */ }
}
// ===== 角色分配 =====
const roleSystemId = ref<string>()
const assignRoleIds = ref<string[]>([])
const currentRoleList = ref<any[]>([])
/**
* 选择系统时加载该系统下的角色列表,并查询用户已有角色
*
* @param sysId - 选择的系统 ID为空则清空角色列表
*
* 并行逻辑:
* 1. 通过角色分页接口获取该系统所有角色pageSize=999 相当于全量)
* 2. 获取当前用户在该系统下已分配的 roleId 列表
* 3. 将已分配的 roleId 预选到角色复选框组
*/
async function onRoleSystemChange(sysId: string | undefined) {
assignRoleIds.value = []
if (!sysId) { currentRoleList.value = []; return }
// 加载该系统下的角色列表pageSize=999 获取全量,角色数量不会很大)
try {
const res = await request.post('/zgapi/v1/admin/role/page', { page: 1, pageSize: 999, systemId: sysId })
currentRoleList.value = res.data?.records ?? []
} catch {
currentRoleList.value = []
}
// 加载用户在该系统下的已有角色
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/role/${sysId}`)
assignRoleIds.value = res.data ?? []
} catch { /* ignore */ }
}
/**
* 保存用户的角色分配
*
* 请求POST /zgapi/v1/admin/user/:id/role
* - body: roleId 数组
* - query: systemId通过 params 传递)
*/
async function saveRoles() {
if (!editId.value || !roleSystemId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/role`, assignRoleIds.value, {
params: { systemId: roleSystemId.value }
})
ElMessage.success('角色已保存')
} catch { /* ignore */ }
}
// ===== 加载与提交 =====
async function loadData() {
const id = route.params.id as string
if (!id) { isEdit.value = false; return }
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
username: data.username ?? '',
realName: data.realName ?? '',
email: data.email ?? '',
phone: data.phone ?? '',
post: data.post ?? '',
password: '',
status: data.status ?? 1,
})
// 加载头像 URL文件服务地址需要 Token否则加载失败
let avatar = data.avatar ?? ''
if (avatar && isFileServiceUrl(avatar)) {
avatar = await refreshAvatarToken(avatar)
}
avatarUrl.value = avatar
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载用户信息失败')
router.back()
}
}
function handleBack() { router.back() }
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
const payload: any = {
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
status: form.status,
}
if (isEdit.value) {
await request.put('/zgapi/v1/admin/user', { ...payload, id: editId.value })
// 管理员重置密码(仅 super_admin密码非空时才调用
if (authStore.isAdmin && form.password) {
await request.put('/zgapi/v1/admin/user/password', { id: editId.value, password: form.password })
}
// 保存系统绑定(失败不中断,绑定可能为空数组)
try { await saveSystems() } catch {}
// 如果有选择角色系统,保存角色(失败不中断)
if (roleSystemId.value) try { await saveRoles() } catch {}
const pwdChanged = !!(authStore.isAdmin && form.password)
ElMessage.success(pwdChanged ? '用户信息已保存,密码已重置' : '用户信息已保存')
} else {
const res = await request.post('/zgapi/v1/admin/user', { ...payload, password: form.password })
// 拿到新用户 ID保存系统绑定和角色分配
const newId = res.data?.id
if (newId) {
editId.value = newId
if (bindSystemIds.value.length > 0) {
try { await saveSystems() } catch {}
}
if (roleSystemId.value) try { await saveRoles() } catch {}
}
ElMessage.success('新增成功')
}
form.password = ''
router.back()
} catch (e: any) {
const msg = e?.response?.data?.msg || e?.message
if (msg && msg !== '请求失败') ElMessage.error(msg)
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchAllSystems()
await loadData()
if (isEdit.value) {
await fetchUserSystems()
}
})
</script>
<style scoped>
.form-page { display:flex; flex-direction:column; }
:deep(.el-page-header) { margin-bottom:4px; }
:deep(.el-page-header + .el-divider) { margin:0 0 20px; }
.form-card { max-width:860px; margin:0 auto; width:100%; }
.section-label { font-size:15px; font-weight:600; color:var(--text-primary); }
.hint-text { font-size:13px; color:var(--text-muted); margin-bottom:16px; }
.form-footer { display:flex; justify-content:flex-end; gap:12px; }
/* ===== 头像上传 ===== */
.avatar-section {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-light);
}
.avatar-wrapper {
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.user-avatar {
box-shadow: 0 4px 12px rgba(99,102,241,0.2);
}
.avatar-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition-fast);
color: #fff;
}
.avatar-wrapper:hover .avatar-overlay {
opacity: 1;
}
.avatar-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.avatar-hint {
font-size: 13px;
color: var(--text-muted);
}
@media (max-width: 768px) {
.form-card { max-width: 100%; padding: 0 8px; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,279 @@
<!--
用户管理列表页
功能
- 按用户名邮箱状态启用/禁用筛选
- 操作列编辑删除带确认弹窗
- 最后登录时间使用 yyyy-MM-dd HH:mm:ss 格式显示
新增/编辑均跳转到独立表单页 UserForm.vue不在本页面内嵌弹窗
删除操作调用 DELETE /zgapi/v1/admin/user/:id
数据来源POST /zgapi/v1/admin/user/page
-->
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="query.email" placeholder="请输入邮箱" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-button-group>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:user:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<!-- 桌面端表格 -->
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realName" label="真实姓名" width="100" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginTime" label="最后登录时间" width="180">
<template #default="{ row }">{{ row.lastLoginTime ? formatDateTime(row.lastLoginTime) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="260" align="center">
<template #default="{ row }">
<el-button type="info" size="small" plain @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button type="primary" size="small" plain v-permission="'system:user:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button type="danger" size="small" plain v-permission="'system:user:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无用户数据" :image-size="100" /></template>
</el-table>
<!-- 手机端卡片 -->
<div v-else class="mobile-cards" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-field"><span class="fld-label">用户名</span><span class="fld-val">{{ row.username }}</span></div>
<div class="card-field"><span class="fld-label">姓名</span><span class="fld-val">{{ row.realName || '-' }}</span></div>
<div class="card-field"><span class="fld-label">邮箱</span><span class="fld-val">{{ row.email || '-' }}</span></div>
<div class="card-field"><span class="fld-label">手机号</span><span class="fld-val">{{ row.phone || '-' }}</span></div>
<div class="card-field"><span class="fld-label">状态</span><span class="fld-val"><el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag></span></div>
<div class="card-field"><span class="fld-label">最后登录</span><span class="fld-val">{{ row.lastLoginTime ? formatDateTime(row.lastLoginTime) : '-' }}</span></div>
<div class="card-actions">
<el-button size="small" plain type="info" @click="showDetail(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button size="small" plain type="primary" v-permission="'system:user:edit'" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button size="small" plain type="danger" v-permission="'system:user:delete'" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</div>
</div>
<el-empty v-if="tableData.length === 0" description="暂无用户数据" :image-size="100" />
</div>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
<!-- 用户详情弹窗 -->
<el-dialog v-model="detailVisible" title="用户详情" width="550px" destroy-on-close>
<el-descriptions v-if="detailRow" :column="2" border size="small">
<el-descriptions-item label="用户名">{{ detailRow.username }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ detailRow.realName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detailRow.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ detailRow.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="岗位">{{ detailRow.post || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailRow.status === 1 ? 'success' : 'danger'" effect="dark" size="small">{{ detailRow.status === 1 ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最后登录时间">{{ detailRow.lastLoginTime ? formatDateTime(detailRow.lastLoginTime) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailRow.createdAt ? formatDateTime(detailRow.createdAt) : '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, MoreFilled, View } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/date'
import { useIsMobile } from '@/composables/useIsMobile'
const isMobile = useIsMobile()
/** 用户类型 */
interface UserItem {
id?: string
username: string
realName: string
email: string
phone: string
post: string
password?: string
status: number
lastLoginTime?: string
createdAt?: string
}
interface SystemItem {
id: string
systemName: string
}
interface RoleItem {
id: string
roleName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
email: '',
status: '' as string | number,
})
const tableData = ref<UserItem[]>([])
const total = ref(0)
const loading = ref(false)
// --- 详情弹窗 ---
const detailVisible = ref(false)
const detailRow = ref<any>(null)
function showDetail(row: any) {
detailRow.value = row
detailVisible.value = true
}
/**
* 分页获取用户列表
* POST /zgapi/v1/admin/user/page — body 传参
* 仅传有值的查询字段,空值不发送以减少冗余参数
*/
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.email) params.email = query.email
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/user/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.email = ''
query.status = ''
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/user/form')
}
function handleEdit(row: UserItem) {
router.push('/admin/user/form/' + row.id)
}
function handleDelete(row: UserItem) {
ElMessageBox.confirm(`确认删除用户"${row.username}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/user/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.actions-desk {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.actions-mob { display: none; }
@media (max-width: 768px) {
.toolbar .el-button { width: 100%; }
.search-card :deep(.el-button-group) {
width: 100%;
display: flex;
}
.search-card :deep(.el-button-group .el-button) {
flex: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- 左侧品牌区 -->
<div class="login-brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<h1 class="brand-title">Mokee Gateway</h1>
<p class="brand-desc">统一登录网关管理平台</p>
<div class="brand-features">
<div class="feature-item">
<span class="feature-dot" />
<span>统一身份认证</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>智能网关转发</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>细粒度权限管控</span>
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-form-panel">
<div class="form-header">
<h2>欢迎回来</h2>
<p>请输入账号密码登录系统</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="submit-btn"
@click="handleLogin"
>
{{ navigating ? '正在跳转...' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<footer class="login-footer">
&copy; 2026 Mokee Gateway &nbsp;|&nbsp;
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">湘ICP备19021539号</a>
</footer>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
/** 登录成功后正在跳转中 — 此时按钮继续转圈,用户可二次点击 */
const navigating = ref(false)
/** 跳转超时定时器 */
let navTimer: ReturnType<typeof setTimeout> | null = null
const form = reactive({ username: '', password: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
/** 清理超时定时器 */
function clearNavTimer() {
if (navTimer) { clearTimeout(navTimer); navTimer = null }
}
onBeforeUnmount(() => clearNavTimer())
/**
* 执行跳转:优先用跨域完整跳转,否则 Vue Router 跳转
* 跳转过程中 loading 持续转圈,不调用 finally 关闭
*/
function doNavigate() {
navigating.value = true
loading.value = true
const webUrl = import.meta.env.VITE_WEB_URL
if (webUrl && webUrl !== window.location.origin) {
window.location.href = webUrl + '/select'
} else {
router.push('/select')
}
// 8 秒超时:如果还在本页说明跳转卡住了,允许用户二次点击
navTimer = setTimeout(() => {
navigating.value = false
loading.value = false
ElMessage.warning('网络较慢,页面跳转超时,请重新点击登录按钮')
}, 8000)
}
async function handleLogin() {
// 已登录成功正在跳转中 → 二次点击直接重新跳转,不重复调用登录接口
if (navigating.value) {
clearNavTimer()
doNavigate()
return
}
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
// 登录成功 → 保持 loading 状态,开始跳转
clearNavTimer()
doNavigate()
} catch {
// 响应拦截器已统一处理所有错误提示(业务错误/网络错误/401/403等
// 此处只需重置 loading 状态,不再重复弹窗
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f8fafc;
padding: 24px;
}
.login-container {
display: flex;
width: 880px;
max-width: 100%;
min-height: 480px;
background: #fff;
border-radius: 20px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 20px 60px -20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* ===== 左侧品牌区 ===== */
.login-brand {
flex: 1;
background: linear-gradient(160deg, #4f46e5 0%, #6366f1 40%, #818cf8 100%);
padding: 48px 40px;
display: flex;
flex-direction: column;
justify-content: center;
color: #fff;
}
.brand-icon {
width: 52px;
height: 52px;
background: rgba(255, 255, 255, 0.18);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
backdrop-filter: blur(10px);
}
.brand-icon svg {
width: 28px;
height: 28px;
}
.brand-title {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.brand-desc {
font-size: 14px;
opacity: 0.8;
font-weight: 400;
margin-bottom: 40px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 14px;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
opacity: 0.85;
}
.feature-dot {
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
flex-shrink: 0;
}
/* ===== 右侧登录表单 ===== */
.login-form-panel {
flex: 1;
padding: 48px 44px;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-header {
margin-bottom: 32px;
}
.form-header h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.form-header p {
font-size: 14px;
color: var(--text-muted);
}
.submit-btn {
width: 100%;
height: 46px;
font-size: 15px;
letter-spacing: 3px;
border-radius: var(--radius-lg);
margin-top: 4px;
}
.login-footer {
margin-top: 28px;
color: var(--text-muted);
font-size: 12px;
}
.login-footer a {
color: var(--text-muted);
text-decoration: none;
}
.login-footer a:hover {
color: var(--color-primary);
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.login-page { padding: 0; justify-content: flex-start; }
.login-container {
flex-direction: column;
min-height: 100vh;
border-radius: 0;
box-shadow: none;
width: 100%;
}
.login-brand {
padding: 40px 24px 24px;
text-align: center;
align-items: center;
}
.brand-icon { width: 44px; height: 44px; margin-bottom: 16px; }
.brand-icon svg { width: 22px; height: 22px; }
.brand-title { font-size: 22px; }
.brand-features { display: none; }
.brand-desc { margin-bottom: 0; }
.login-form-panel { padding: 24px 24px 40px; }
.form-header h2 { font-size: 20px; }
.login-footer { display: none; }
}
</style>

View File

@@ -0,0 +1,356 @@
<!--
系统选择页面登录后的第一屏
功能
1. 顶栏品牌标识 + 后台管理入口仅管理员可见+ 退出按钮
2. 用户问候区头像 + "你好,{姓名}" + 提示语
3. 系统卡片网格展示用户有权访问的所有业务系统
4. 点击系统卡片后
- 调用 authStore.selectSystem() 生成 Token 并写入 localStorage
- gateway 系统跳转到 /admin 管理后台
- 其他系统跳转到其配置的 frontUrl
图标渲染
- getIcon(sys.icon) 使用 getIconComponent 动态解析图标组件名
- isIconUrl(v) 判断是上传的 URL 图片还是 Element Plus 组件名
- 找不到图标时降级使用 Grid 图标
卡片渐变8 种预定义渐变色按索引循环使用
数据来源
- 用户信息authStore.fetchUserInfo() GET /zgapi/v1/auth/user/info
- 系统列表authStore.fetchSystemList() GET /zgapi/v1/auth/system/list
-->
<template>
<div class="select-page">
<!-- 顶栏 -->
<header class="select-topbar">
<div class="topbar-brand">
<svg class="brand-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="brand-text">Mokee Gateway</span>
</div>
<div class="topbar-actions">
<el-button v-if="authStore.isAdmin" type="primary" size="default" @click="router.push('/admin')">
<el-icon><Setting /></el-icon>后台管理
</el-button>
<el-button text @click="handleLogout" style="color: var(--text-muted)">
<el-icon><SwitchButton /></el-icon>退出
</el-button>
</div>
</header>
<!-- 内容 -->
<div class="select-body">
<div class="select-hero">
<el-avatar :size="56" :src="authStore.userInfo?.avatar" class="hero-avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<h2 class="hero-greeting">
你好{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}
</h2>
<p class="hero-sub">请选择要访问的业务系统</p>
</div>
<div class="system-grid" v-if="authStore.systemList.length > 0">
<div
v-for="(sys, i) in authStore.systemList"
:key="sys.systemId"
class="system-card"
@click="handleSelectSystem(sys)"
>
<div class="card-icon-box" :style="{ background: isIconUrl(sys.icon) ? 'transparent' : gradients[i % gradients.length] }">
<img v-if="isIconUrl(sys.icon)" :src="sys.icon" class="card-icon-img" />
<el-icon v-else :size="28"><component :is="getIcon(sys.icon)" /></el-icon>
</div>
<div class="card-info">
<div class="card-title">{{ sys.systemName }}</div>
<div class="card-code">{{ sys.systemCode }}</div>
<div class="card-desc">{{ sys.description || '暂无描述' }}</div>
</div>
<el-icon class="card-arrow" :size="16"><ArrowRight /></el-icon>
</div>
</div>
<el-empty
v-else
description="暂无可用系统,请联系管理员配置"
:image-size="140"
>
<el-button type="primary" @click="authStore.fetchSystemList()">刷新</el-button>
</el-empty>
</div>
<footer class="select-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">湘ICP备19021539号</a>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { SwitchButton, Grid, Setting, ArrowRight } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import { useAuthStore, type SystemInfo } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
// 8 种预定义渐变色,按系统卡片索引循环使用
// 使每个系统卡片拥有不同的视觉色彩标识
const gradients = [
'linear-gradient(135deg, #6366f1, #818cf8)',
'linear-gradient(135deg, #10b981, #34d399)',
'linear-gradient(135deg, #f59e0b, #fbbf24)',
'linear-gradient(135deg, #ef4444, #f87171)',
'linear-gradient(135deg, #8b5cf6, #a78bfa)',
'linear-gradient(135deg, #06b6d4, #22d3ee)',
'linear-gradient(135deg, #ec4899, #f472b6)',
'linear-gradient(135deg, #3b82f6, #60a5fa)',
]
// 页面加载时获取用户信息和系统列表
onMounted(async () => {
await authStore.fetchUserInfo()
await authStore.fetchSystemList()
})
/**
* 解析图标:先尝试从 iconMap 查找 Element Plus 图标组件,找不到降级为 Grid 图标
*
* @param icon - 系统配置中的 icon 字段值(可能是组件名或 URL
* @returns 图标组件Grid 为兜底)
*/
function getIcon(icon: string) {
return getIconComponent(icon) || Grid
}
/**
* 判断图标值是否为 URL上传的文件图标而非 Element Plus 组件名
*
* @param v - 图标值
* @returns true 表示是 URL需用 <img> 渲染false 表示是组件名(用 <el-icon> 渲染)
*/
function isIconUrl(v: string) {
return v && (v.startsWith('http') || v.startsWith('/zgapi'))
}
/**
* 选择系统 — 核心业务逻辑
*
* 流程:
* 1. 调用 authStore.selectSystem(systemId) → 后端生成系统级 Token
* 2. gateway 系统:跳转到主应用域名 + /admin使用 window.location.href 跨域名跳转)
* 3. 其他系统:跳转到其配置的 frontUrl
* 4. 未配置 frontUrl提示用户
*
* gateway 系统特殊处理的原因:
* - 管理后台的打包产物el-admin chunk不应从 login 域名加载
* - 通过 VITE_WEB_URL 环境变量指定管理后台的实际部署域名
*/
async function handleSelectSystem(sys: SystemInfo) {
try {
await authStore.selectSystem(sys.systemId)
if (sys.systemCode === 'gateway') {
// 跳转到主应用域名,避免从 login 域名加载大 JS chunk
const webUrl = import.meta.env.VITE_WEB_URL || window.location.origin
window.location.href = webUrl + '/admin'
return
}
if (sys.frontUrl) {
window.location.href = sys.frontUrl
} else {
ElMessage.warning('该系统未配置前端地址')
}
} catch {
ElMessage.error('切换系统失败')
}
}
/** 退出登录:调用后端注销接口,然后跳转到登录页 */
async function handleLogout() {
try { await authStore.logout() } catch {}
router.push('/login')
}
</script>
<style scoped>
.select-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* ===== 顶栏 ===== */
.select-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: #fff;
border-bottom: 1px solid var(--border-color);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-svg {
width: 26px;
height: 26px;
color: var(--color-primary);
}
.brand-text {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== 内容 ===== */
.select-body {
flex: 1;
padding: 48px 40px 60px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.select-hero {
text-align: center;
margin-bottom: 44px;
}
.hero-avatar {
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2);
}
.hero-greeting {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.hero-sub {
font-size: 15px;
color: var(--text-muted);
}
/* ===== 系统卡片网格 ===== */
.system-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.system-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-xs);
}
.system-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
border-color: #cbd5e1;
}
.card-icon-box {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.card-icon-img { width:40px; height:40px; object-fit:contain; }
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-code {
font-size: 12px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
margin-bottom: 4px;
}
.card-desc {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-arrow {
color: var(--text-muted);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.system-card:hover .card-arrow {
color: var(--color-primary);
transform: translateX(3px);
}
@media (max-width: 768px) {
.select-body { padding: 24px 16px 40px; }
.select-hero { margin-bottom: 28px; }
.hero-greeting { font-size: 20px; }
.system-grid { grid-template-columns: 1fr; gap: 12px; }
.select-topbar { padding: 0 12px; }
}
.select-footer {
text-align: center;
padding: 16px 0 8px;
flex-shrink: 0;
}
.select-footer a {
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
}
.select-footer a:hover {
color: var(--color-primary);
}
</style>

View File

@@ -0,0 +1,248 @@
<!--
用户头像下拉 Widget 核心组件
功能
- 加载中 骨架屏
- 未登录 Token API 失败 "登录"按钮点击跳转统一登录页
- 已登录 头像 + 用户名 + 下拉菜单仅两项切换系统退出登录
头像处理
- 如果头像 URL 指向文件服务file.wg.zgitm.com每次组件挂载时获取新下载 Token
- 数据库只存基础 URL不带 ?token=xxxToken 一次一密每次加载重新获取
下拉菜单仅两项没有个人中心
- 🔄 切换系统 window.location.href = selectUrl
- 🚪 退出登录 POST logout Cookie window.location.href = loginUrl
Props main.ts data-* 属性读取后传入
-->
<template>
<div class="mokee-uc-root">
<!-- ===== 加载中骨架屏 ===== -->
<div v-if="loading" class="mokee-uc-chip mokee-uc-chip--loading">
<div class="mokee-uc-skeleton-avatar" />
<div class="mokee-uc-skeleton-name" />
</div>
<!-- ===== 未登录登录按钮 ===== -->
<div v-else-if="!userInfo" class="mokee-uc-chip mokee-uc-chip--guest" @click="goLogin">
<el-icon :size="16"><User /></el-icon>
<span class="mokee-uc-name">登录</span>
</div>
<!-- ===== 已登录头像 + 用户名 + 下拉 ===== -->
<el-dropdown v-else trigger="hover" placement="bottom-end">
<div class="mokee-uc-chip mokee-uc-chip--logged">
<el-avatar :size="28" :src="userInfo.avatar" class="mokee-uc-avatar">
{{ userInitial }}
</el-avatar>
<span class="mokee-uc-name">{{ displayName }}</span>
<el-icon :size="12" class="mokee-uc-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goSelectSystem">
<el-icon><Switch /></el-icon>切换系统
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElAvatar, ElDropdown, ElDropdownMenu, ElDropdownItem, ElIcon } from 'element-plus'
import { User, Switch, SwitchButton, ArrowDown } from '@element-plus/icons-vue'
import { fetchUserInfo, requestLogout, refreshFileServiceUrl } from './api'
import { removeToken } from './cookie'
import type { UserInfo } from './api'
// ===== Props由 main.ts 从 data-* 属性读取后传入) =====
const props = defineProps({
/** 网关 API 地址 */
apiBase: {
type: String,
default: () => window.location.origin,
},
/** 统一登录页完整 URL */
loginUrl: {
type: String,
default: () => window.location.origin + '/login',
},
/** 切换系统页完整 URL */
selectUrl: {
type: String,
default: () => window.location.origin + '/select',
},
})
// ===== 状态 =====
const loading = ref<boolean>(true)
const userInfo = ref<UserInfo | null>(null)
// ===== 计算属性 =====
/** 用户名首字符(文字头像 fallback */
const userInitial = computed<string>(() => {
if (!userInfo.value) return 'U'
return (userInfo.value.realName || userInfo.value.username || 'U').charAt(0)
})
/** 显示名称:优先 realName其次 username */
const displayName = computed<string>(() => {
if (!userInfo.value) return ''
return userInfo.value.realName || userInfo.value.username || ''
})
// ===== 生命周期 =====
onMounted(async () => {
loading.value = true
const data = await fetchUserInfo(props.apiBase)
if (data) {
// 头像来自文件服务 → 每次都获取新下载 TokenToken 一次一密)
if (data.avatar) {
data.avatar = await refreshFileServiceUrl(props.apiBase, data.avatar)
}
userInfo.value = data
} else {
userInfo.value = null
}
loading.value = false
})
// ===== 交互方法 =====
/** 跳转统一登录页 */
function goLogin(): void {
window.location.href = props.loginUrl
}
/** 跳转切换系统页 */
function goSelectSystem(): void {
window.location.href = props.selectUrl
}
/** 退出登录 */
async function handleLogout(): Promise<void> {
// 1. 通知服务端销毁 sessionfire-and-forget
await requestLogout(props.apiBase)
// 2. 清除 Cookie 中的 Token
removeToken()
// 3. 完整页面跳转到登录页(清空所有内存状态)
window.location.href = props.loginUrl
}
</script>
<style scoped>
/* ================================================================
UserChip 组件样式
命名空间:.mokee-uc-{Block}__{Element}--{Modifier}
CSS 变量:--mokee-uc-{name}
策略:自给自足,不依赖宿主页面的任何 CSS 变量或类名
================================================================ */
/* ===== 根容器 + CSS 变量 ===== */
.mokee-uc-root {
--mokee-uc-text-primary: #1f2937;
--mokee-uc-text-secondary: #6b7280;
--mokee-uc-text-muted: #9ca3af;
--mokee-uc-bg-hover: #f3f4f6;
--mokee-uc-border-color: #e5e7eb;
--mokee-uc-color-primary: #6366f1;
--mokee-uc-radius-sm: 4px;
--mokee-uc-radius-full: 24px;
--mokee-uc-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
--mokee-uc-transition: 0.15s ease;
display: inline-flex;
align-items: center;
font-family: var(--mokee-uc-font-family);
font-size: 14px;
line-height: 1.5;
color: var(--mokee-uc-text-primary);
-webkit-font-smoothing: antialiased;
}
/* ===== 用户条(公共) ===== */
.mokee-uc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px 5px 5px;
border-radius: var(--mokee-uc-radius-full);
cursor: pointer;
border: 1px solid transparent;
transition: all var(--mokee-uc-transition);
user-select: none;
white-space: nowrap;
}
.mokee-uc-chip:hover {
background: var(--mokee-uc-bg-hover);
border-color: var(--mokee-uc-border-color);
}
/* ===== 头像 ===== */
.mokee-uc-avatar {
flex-shrink: 0;
}
/* ===== 用户名 ===== */
.mokee-uc-name {
font-size: 14px;
font-weight: 500;
color: var(--mokee-uc-text-secondary);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 下拉箭头 ===== */
.mokee-uc-chevron {
color: var(--mokee-uc-text-muted);
flex-shrink: 0;
}
/* ===== 状态:加载中 ===== */
.mokee-uc-chip--loading {
cursor: default;
gap: 10px;
padding: 5px 12px;
}
.mokee-uc-chip--loading:hover {
background: transparent;
border-color: transparent;
}
.mokee-uc-skeleton-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--mokee-uc-border-color);
animation: mokee-uc-pulse 1.5s ease-in-out infinite;
}
.mokee-uc-skeleton-name {
width: 50px;
height: 14px;
border-radius: var(--mokee-uc-radius-sm);
background: var(--mokee-uc-border-color);
animation: mokee-uc-pulse 1.5s ease-in-out infinite;
}
@keyframes mokee-uc-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ===== 状态:未登录 ===== */
.mokee-uc-chip--guest {
padding: 5px 14px;
color: var(--mokee-uc-color-primary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,139 @@
/**
* API 调用层 — 封装 Widget 需要的 3 个网关 API
*
* 设计原则:
* - 直接用 fetch不依赖 axios省 ~15KB 包体积)
* - 不依赖 Pinia、request.ts 等网关后台专用模块
* - 所有函数以 apiBase 为参数,方便外部配置
* - 错误静默处理,失败返回 null让组件安全降级
*/
import { getToken } from './cookie'
/** 用户信息(与网关 GET /zgapi/v1/auth/user/info 响应一致) */
export interface UserInfo {
userId: string
username: string
realName: string
email: string
phone: string
avatar: string | null
post: string
}
/** 文件服务域名(用于判断头像 URL 是否需要刷新 Token */
const FILE_SERVICE_HOST = 'file.wg.zgitm.com'
/** 判断 URL 是否为文件服务地址(需要动态下载 Token */
function isFileServiceUrl(url: string): boolean {
if (!url) return false
return url.includes(FILE_SERVICE_HOST)
}
/** 从 URL 中去掉 ?token=xxx 参数,保留基础路径 */
function stripToken(url: string): string {
return url.split('?')[0]
}
/**
* 获取当前用户信息
*
* GET {apiBase}/zgapi/v1/auth/user/info
* 请求头携带 Authorization: Bearer {token}token 从 Cookie 读取)
*
* @param apiBase - 网关 API 地址(如 https://gw.server.zgitm.com
* @returns 用户信息对象,未登录/Token过期/网络异常返回 null
*/
export async function fetchUserInfo(apiBase: string): Promise<UserInfo | null> {
const token = getToken()
if (!token) return null
try {
const res = await fetch(`${apiBase}/zgapi/v1/auth/user/info`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json;charset=UTF-8',
},
credentials: 'include',
})
if (res.status === 401) return null // Token 过期,静默降级
if (!res.ok) return null
const body = await res.json()
// 网关统一响应格式: { code: 200, data: {...} }
if (body.code !== 200) return null
return body.data as UserInfo
} catch {
// 网络异常,静默降级为未登录
return null
}
}
/**
* 退出登录
*
* POST {apiBase}/zgapi/v1/auth/logout
* fire-and-forget: 失败不阻塞后续 Cookie 清除和页面跳转
*
* @param apiBase - 网关 API 地址
*/
export async function requestLogout(apiBase: string): Promise<void> {
const token = getToken()
if (!token) return
try {
await fetch(`${apiBase}/zgapi/v1/auth/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json;charset=UTF-8',
},
credentials: 'include',
})
} catch {
// fire-and-forget: 即使失败也继续后续流程
}
}
/**
* 为文件服务 URL 获取新的下载 Token
*
* 头像存储在文件对象存储服务中,数据库只存基础 URL如 /api/download/{fileId}
* Token 一次一密,每次加载页面都需要重新获取并拼接完整的下载地址。
*
* POST {apiBase}/zgapi/v1/open/token → 获取新 Token → 拼接 baseUrl?token=xxx
*
* @param apiBase - 网关 API 地址
* @param url - 原始头像 URL可能是带过期 Token 的 URL 或纯 baseUrl
* @returns 拼接了新 Token 的完整 URL获取失败返回原 URL 降级
*/
export async function refreshFileServiceUrl(apiBase: string, url: string): Promise<string> {
if (!url || !isFileServiceUrl(url)) return url
const token = getToken()
if (!token) return url
try {
const baseUrl = stripToken(url)
const res = await fetch(`${apiBase}/zgapi/v1/open/token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json;charset=UTF-8',
},
credentials: 'include',
body: JSON.stringify({ systemCode: 'gateway' }),
})
if (!res.ok) return url
const body = await res.json()
if (body.code !== 200 || !body.data?.token) return url
return `${baseUrl}?token=${body.data.token}`
} catch {
return url
}
}

View File

@@ -0,0 +1,42 @@
/**
* Cookie 工具 — Token 的 Cookie 读写
*
* 与 src/utils/request.ts 和 src/stores/auth.ts 的 Cookie 逻辑完全一致,
* 保持网关 Token 机制统一。
*
* Cookie 属性:
* - path=/ : 全站可读
* - max-age=7200 : 2小时与 JWT Token 过期时间一致
* - SameSite=Lax : 允许同站内跨子域携带
* - domain : 由 data-cookie-domain 配置(如 .zgitm.com用于跨子域共享 Token
*/
const TOKEN_KEY = 'token'
/** 从占位元素读取 Cookie domain 配置 */
function readCookieDomain(): string {
const el = document.getElementById('mokee-user-chip')
if (el) {
const d = el.getAttribute('data-cookie-domain')
if (d !== null) return d
}
return ''
}
/** 构建 Cookie 的 domain 部分 */
function getDomainPart(): string {
const domain = readCookieDomain()
return domain ? `;domain=${domain}` : ''
}
/** 从 Cookie 读取 Token */
export function getToken(): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${TOKEN_KEY}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
/** 清除 Cookie 中的 Token */
export function removeToken(): void {
const domainPart = getDomainPart()
document.cookie = `${TOKEN_KEY}=;path=/;max-age=0${domainPart}`
}

View File

@@ -0,0 +1,92 @@
/**
* UserChip Widget — 自动初始化入口
*
* 外部系统只需引入这一个 JS 文件,页面上放置 <div id="mokee-user-chip"></div>
* 脚本自动扫描并渲染用户头像下拉组件。
*
* 使用方式(生产环境):
* <link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
* <div id="mokee-user-chip" data-api-base="https://gw.server.zgitm.com"></div>
* <script src="https://web.gw.zgitm.com/widgets/user-chip.js"></script>
*
* loginUrl 和 selectUrl 自动从脚本 URL 推断,无需手动配置:
* 脚本 src = https://web.gw.zgitm.com/widgets/user-chip.js
* → loginUrl = https://web.gw.zgitm.com/login
* → selectUrl = https://web.gw.zgitm.com/select
*
* 为什么不用 window.location.origin
* 外部系统运行在自己域名下(如 app1.zgitm.com
* window.location.origin 是外部系统域名,不是网关域名。
* 正确做法是从 Widget 脚本自身的 <script src="..."> URL 提取。
*
* 打包方式 (IIFE)
* vite build --config vite.widget.config.ts
* 产物dist/widgets/user-chip.js浏览器可直接执行
*
* 注意:此文件无任何 exportIIFE 打包后直接执行。
*/
import { createApp } from 'vue'
import UserChip from './UserChip.vue'
/**
* 从当前 script 标签的 src 推断网关 Web 域名
*
* 查找 DOM 中引用 user-chip.js 的 <script> 标签,提取其 origin。
* 因为 Widget JS 部署在网关 Web 域名下,这个 origin 就是 loginUrl/selectUrl 的基础。
*
* @returns 脚本所在域名的 origin如 https://web.gw.zgitm.com解析失败返回 window.location.origin
*/
function getScriptOrigin(): string {
try {
const scripts = document.querySelectorAll('script[src*="user-chip.js"]')
const src = scripts[scripts.length - 1]?.getAttribute('src') || ''
if (src) return new URL(src).origin
} catch { /* 解析失败用降级值 */ }
return window.location.origin
}
/**
* 读取占位元素的 data-* 配置
*
* 规则:
* - apiBase优先 data-api-base其次脚本域名生产环境必须显式配置因为 API 网关在不同域名)
* - loginUrl优先 data-login-url其次脚本域名 + /login
* - selectUrl优先 data-select-url其次脚本域名 + /select
*/
function readConfig(el: Element) {
const scriptOrigin = getScriptOrigin()
return {
apiBase: el.getAttribute('data-api-base') || scriptOrigin,
loginUrl: el.getAttribute('data-login-url') || (scriptOrigin + '/login'),
selectUrl: el.getAttribute('data-select-url') || (scriptOrigin + '/select'),
}
}
/**
* 自动初始化
*
* 扫描页面上所有 #mokee-user-chip 元素,每个创建一个独立 Vue 应用实例。
* 如果页面没有占位元素,静默退出(不报错、不渲染任何东西)。
*/
function autoInit(): void {
const containers = document.querySelectorAll('#mokee-user-chip')
if (containers.length === 0) return
containers.forEach((container) => {
const props = readConfig(container)
const app = createApp(UserChip, props)
app.mount(container)
})
}
// DOM 就绪后自动初始化
// 如果脚本放在页面底部(</body> 前DOM 可能已就绪,
// 此时 readyState 不是 'loading'DOMContentLoaded 不会再触发
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit)
} else {
autoInit()
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,81 @@
/**
* Vite 构建配置文件
*
* 核心职责:
* 1. Element Plus 按需加载 — 通过 unplugin-vue-components 自动解析组件,无需手动 import
* 2. 分包策略 — 将 node_modules 拆分为独立 chunk区分基础组件与后台组件
* 3. 首屏优化 — 去除 Element Plus modulepreload 标签,避免登录页加载后台 JS/CSS
*
* 分包设计:
* - echarts: 单独打包,仪表盘页按需加载
* - vue-vendor: Vue/Router/Pinia 核心框架,长期缓存
* - el-admin: 后台管理页的 Element Plus 组件,进后台后一次性加载
* - 基础组件button/input/icon等: 不单独分包,保持独立小 chunk供 login/select 页面按需加载
*/
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// login/select 页面用到的 Element Plus 基础组件,保持独立不打包进 el-admin
// 这些组件体积小、首屏即用,独立分包可避免登录页下载后台才用的 JS
const EL_BASE = [
'button', 'input', 'form', 'form-item',
'icon', 'avatar', 'empty',
'message', 'notification', 'loading',
'popper', 'tooltip', 'overlay', 'config-provider',
'scrollbar', 'focus-trap', 'collection',
]
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
dts: 'src/types/components.d.ts',
}),
// 去掉 Element Plus 预加载:登录/选系统页不预下载后台才用的 JS/CSS
// 原理Vite 默认会对所有动态 import 的模块生成 <link rel="modulepreload"> 标签
// Element Plus 按需加载时也会被预加载,导致首屏下载大量无用 JS
// 本插件在构建 HTML 时去除这些标签,让组件在真正使用时才加载
{
name: 'strip-el-preloads',
transformIndexHtml(html) {
// 去掉所有 modulepreloadElement Plus 组件全部按需加载)
html = html.replace(/<link rel="modulepreload"[^>]*>/g, '')
// 去掉 el-admin CSS后台组件样式login/select 不需要)
html = html.replace(/<link[^>]*el-admin[^>]*>/g, '')
return html
},
},
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5001,
},
build: {
target: 'es2015',
cssTarget: 'chrome61',
rollupOptions: {
output: {
manualChunks(id) {
// ECharts 体积大(~1MB独立分包仅在仪表盘页面才动态加载
if (id.includes('node_modules/echarts')) return 'echarts'
// Vue 核心框架长期不变,独立分包以获得最佳缓存命中率
if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) return 'vue-vendor'
if (id.includes('node_modules/element-plus')) {
// 基础组件保持独立小 chunklogin/select 页面按需加载),不打包进 el-admin
if (EL_BASE.some(c => id.includes(`/${c}/`) || id.includes(`/${c}.`) || id.endsWith(`/${c}`))) return
// 管理后台组件打成一个 el-admin chunk进后台一次性加载减少后续页面切换的网络请求
return 'el-admin'
}
},
},
},
},
})

View File

@@ -0,0 +1,43 @@
/**
* Vite Library Mode 构建配置 — 用户头像下拉 Widget
*
* 产物:
* - dist/widgets/user-chip.js IIFE 格式,包含 Vue + Element Plus 组件 + 业务代码
* - dist/widgets/user-chip.css 提取的 CSS
*
* 使用npm run build:widget
*/
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist/widgets',
lib: {
entry: path.resolve(__dirname, 'src/widgets/user-chip/main.ts'),
name: 'MokeeUserChip',
formats: ['iife'],
fileName: () => 'user-chip.js',
},
rollupOptions: {
// 全部依赖打入包内,外部系统无需前置依赖
external: [],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) return 'user-chip.css'
return assetInfo.name || '[name]-[hash][extname]'
},
},
},
target: 'es2015',
cssTarget: 'chrome61',
sourcemap: false,
},
})