初始化
This commit is contained in:
5
mokee-gateway-web/.env.development
Normal file
5
mokee-gateway-web/.env.development
Normal 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=
|
||||
5
mokee-gateway-web/.env.production
Normal file
5
mokee-gateway-web/.env.production
Normal 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
1
mokee-gateway-web/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
18
|
||||
945
mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue
Normal file
945
mokee-gateway-web/_backup_20260613_widget/IntegrationDoc.vue
Normal 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<Map<String, String>> CTX = new ThreadLocal<>();
|
||||
|
||||
public static void set(Map<String, String> user) { CTX.set(user); }
|
||||
public static Map<String, String> 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<String, String> user = new HashMap<>();
|
||||
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<Order> 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} | <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 <token></code><br>
|
||||
② Query 参数:<code>/api/download/{fileId}?token=<token></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} | <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": "<h1>邮件内容</h1>",
|
||||
"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": "<h1>你好</h1><p>这是一封测试邮件</p>",
|
||||
"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>
|
||||
29
mokee-gateway-web/_backup_20260613_widget/package.json
Normal file
29
mokee-gateway-web/_backup_20260613_widget/package.json
Normal 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
10
mokee-gateway-web/auto-imports.d.ts
vendored
Normal 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 {
|
||||
|
||||
}
|
||||
6
mokee-gateway-web/backup-on-demand/App.vue.bak
Normal file
6
mokee-gateway-web/backup-on-demand/App.vue.bak
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
22
mokee-gateway-web/backup-on-demand/main.ts.bak
Normal file
22
mokee-gateway-web/backup-on-demand/main.ts.bak
Normal 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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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<Map<String, String>> CTX = new ThreadLocal<>();
|
||||
|
||||
public static void set(Map<String, String> user) { CTX.set(user); }
|
||||
public static Map<String, String> 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<String, String> user = new HashMap<>();
|
||||
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<Order> 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>
|
||||
@@ -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">
|
||||
© 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>
|
||||
@@ -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>
|
||||
28
mokee-gateway-web/backup-on-demand/vite.config.ts.bak
Normal file
28
mokee-gateway-web/backup-on-demand/vite.config.ts.bak
Normal 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
61
mokee-gateway-web/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
106
mokee-gateway-web/dist-widget/demo-widget.html
Normal file
106
mokee-gateway-web/dist-widget/demo-widget.html
Normal 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"><!-- 1. 引入样式 --></span><br/>
|
||||
<link rel=<span class="hl">"stylesheet"</span> href=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.css"</span> /><br/><br/>
|
||||
<span class="cm"><!-- 2. 占位元素(只需配 API 网关地址) --></span><br/>
|
||||
<div id=<span class="hl">"mokee-user-chip"</span><br/>
|
||||
data-api-base=<span class="hl">"https://gw.server.zgitm.com"</span>></div><br/><br/>
|
||||
<span class="cm"><!-- 3. 引入脚本(自动渲染,login/select 地址自动推断) --></span><br/>
|
||||
<script src=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.js"</span>></script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第 3 步:引入 Widget 脚本(放 body 底部,确保 DOM 就绪) -->
|
||||
<script src="/widgets/user-chip.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
mokee-gateway-web/dist-widget/user-chip.css
Normal file
1
mokee-gateway-web/dist-widget/user-chip.css
Normal 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}
|
||||
91
mokee-gateway-web/dist-widget/user-chip.js
Normal file
91
mokee-gateway-web/dist-widget/user-chip.js
Normal file
File diff suppressed because one or more lines are too long
13
mokee-gateway-web/index.html
Normal file
13
mokee-gateway-web/index.html
Normal 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>
|
||||
50
mokee-gateway-web/memory/session-20260612-1.md
Normal file
50
mokee-gateway-web/memory/session-20260612-1.md
Normal 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
2754
mokee-gateway-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
mokee-gateway-web/package.json
Normal file
30
mokee-gateway-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
106
mokee-gateway-web/public/demo-widget.html
Normal file
106
mokee-gateway-web/public/demo-widget.html
Normal 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"><!-- 1. 引入样式 --></span><br/>
|
||||
<link rel=<span class="hl">"stylesheet"</span> href=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.css"</span> /><br/><br/>
|
||||
<span class="cm"><!-- 2. 占位元素(只需配 API 网关地址) --></span><br/>
|
||||
<div id=<span class="hl">"mokee-user-chip"</span><br/>
|
||||
data-api-base=<span class="hl">"https://gw.server.zgitm.com"</span>></div><br/><br/>
|
||||
<span class="cm"><!-- 3. 引入脚本(自动渲染,login/select 地址自动推断) --></span><br/>
|
||||
<script src=<span class="hl">"https://web.gw.zgitm.com/widgets/user-chip.js"</span>></script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第 3 步:引入 Widget 脚本(放 body 底部,确保 DOM 就绪) -->
|
||||
<script src="/widgets/user-chip.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
mokee-gateway-web/src/App.vue
Normal file
19
mokee-gateway-web/src/App.vue
Normal 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>
|
||||
171
mokee-gateway-web/src/components/IconPicker.vue
Normal file
171
mokee-gateway-web/src/components/IconPicker.vue
Normal 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>
|
||||
140
mokee-gateway-web/src/components/UploadPanel.vue
Normal file
140
mokee-gateway-web/src/components/UploadPanel.vue
Normal 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>
|
||||
18
mokee-gateway-web/src/composables/useIsMobile.ts
Normal file
18
mokee-gateway-web/src/composables/useIsMobile.ts
Normal 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
|
||||
}
|
||||
20
mokee-gateway-web/src/directives/permission.ts
Normal file
20
mokee-gateway-web/src/directives/permission.ts
Normal 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
7
mokee-gateway-web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
43
mokee-gateway-web/src/main.ts
Normal file
43
mokee-gateway-web/src/main.ts
Normal 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'
|
||||
// 显式引入命令式组件 CSS(ElMessage/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)
|
||||
}
|
||||
})
|
||||
|
||||
208
mokee-gateway-web/src/router/index.ts
Normal file
208
mokee-gateway-web/src/router/index.ts
Normal 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
|
||||
}
|
||||
|
||||
// 设置默认 favicon(Mokee 三角 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
|
||||
150
mokee-gateway-web/src/stores/auth.ts
Normal file
150
mokee-gateway-web/src/stores/auth.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
66
mokee-gateway-web/src/stores/tabs.ts
Normal file
66
mokee-gateway-web/src/stores/tabs.ts
Normal 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 }
|
||||
})
|
||||
79
mokee-gateway-web/src/stores/upload.ts
Normal file
79
mokee-gateway-web/src/stores/upload.ts
Normal 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 }
|
||||
})
|
||||
450
mokee-gateway-web/src/styles/global.css
Normal file
450
mokee-gateway-web/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
61
mokee-gateway-web/src/types/components.d.ts
vendored
Normal file
61
mokee-gateway-web/src/types/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
127
mokee-gateway-web/src/utils/date.ts
Normal file
127
mokee-gateway-web/src/utils/date.ts
Normal 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
|
||||
}
|
||||
22
mokee-gateway-web/src/utils/icons.ts
Normal file
22
mokee-gateway-web/src/utils/icons.ts
Normal 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
|
||||
69
mokee-gateway-web/src/utils/request.ts
Normal file
69
mokee-gateway-web/src/utils/request.ts
Normal 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
|
||||
564
mokee-gateway-web/src/views/admin/AdminLayout.vue
Normal file
564
mokee-gateway-web/src/views/admin/AdminLayout.vue
Normal 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>
|
||||
324
mokee-gateway-web/src/views/admin/Dashboard.vue
Normal file
324
mokee-gateway-web/src/views/admin/Dashboard.vue
Normal 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>
|
||||
341
mokee-gateway-web/src/views/admin/PersonalView.vue
Normal file
341
mokee-gateway-web/src/views/admin/PersonalView.vue
Normal 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>
|
||||
61
mokee-gateway-web/src/views/admin/config/SystemConfig.vue
Normal file
61
mokee-gateway-web/src/views/admin/config/SystemConfig.vue
Normal 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>
|
||||
216
mokee-gateway-web/src/views/admin/dict/DictForm.vue
Normal file
216
mokee-gateway-web/src/views/admin/dict/DictForm.vue
Normal 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>
|
||||
331
mokee-gateway-web/src/views/admin/dict/DictList.vue
Normal file
331
mokee-gateway-web/src/views/admin/dict/DictList.vue
Normal 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>
|
||||
286
mokee-gateway-web/src/views/admin/email/EmailList.vue
Normal file
286
mokee-gateway-web/src/views/admin/email/EmailList.vue
Normal 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>
|
||||
468
mokee-gateway-web/src/views/admin/file/FileList.vue
Normal file
468
mokee-gateway-web/src/views/admin/file/FileList.vue
Normal 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>
|
||||
190
mokee-gateway-web/src/views/admin/log/LoginLogList.vue
Normal file
190
mokee-gateway-web/src/views/admin/log/LoginLogList.vue
Normal 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)
|
||||
|
||||
/**
|
||||
* 分页获取登录日志
|
||||
*
|
||||
* 请求方式:POST(body 传参,非 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>
|
||||
280
mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue
Normal file
280
mokee-gateway-web/src/views/admin/log/OpenTokenLogList.vue
Normal 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>
|
||||
445
mokee-gateway-web/src/views/admin/log/OperationLogList.vue
Normal file
445
mokee-gateway-web/src/views/admin/log/OperationLogList.vue
Normal 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>
|
||||
366
mokee-gateway-web/src/views/admin/menu/MenuForm.vue
Normal file
366
mokee-gateway-web/src/views/admin/menu/MenuForm.vue
Normal 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>
|
||||
309
mokee-gateway-web/src/views/admin/menu/MenuList.vue
Normal file
309
mokee-gateway-web/src/views/admin/menu/MenuList.vue
Normal 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>
|
||||
226
mokee-gateway-web/src/views/admin/role/RoleForm.vue
Normal file
226
mokee-gateway-web/src/views/admin/role/RoleForm.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<!--
|
||||
角色表单页(新增/编辑)
|
||||
|
||||
功能:
|
||||
- 基本信息:角色名称、角色编码(编辑时禁用)、所属系统、状态
|
||||
- 其他信息:描述文本
|
||||
- 所属系统下拉数据来源统一为 GET /zgapi/v1/admin/system/list
|
||||
|
||||
修复说明:
|
||||
- 系统列表端点已统一为 /system/list(RoleList.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>
|
||||
380
mokee-gateway-web/src/views/admin/role/RoleList.vue
Normal file
380
mokee-gateway-web/src/views/admin/role/RoleList.vue
Normal 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>
|
||||
236
mokee-gateway-web/src/views/admin/system/ApiForm.vue
Normal file
236
mokee-gateway-web/src/views/admin/system/ApiForm.vue
Normal 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 读取预设系统 ID(ApiList 页跳转时携带)
|
||||
* 编辑模式:通过 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>
|
||||
314
mokee-gateway-web/src/views/admin/system/ApiList.vue
Normal file
314
mokee-gateway-web/src/views/admin/system/ApiList.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<!--
|
||||
API 接口管理列表页
|
||||
|
||||
功能:
|
||||
- 按所属系统(下拉选择)、接口路径、请求方式筛选
|
||||
- 请求方式列使用彩色标签(GET=绿, POST=橙, PUT=蓝, DELETE=红)
|
||||
- 系统选择器默认值从 localStorage.currentSystemId 读取
|
||||
- 新增时通过 query 参数携带当前 systemId,ApiForm.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>
|
||||
201
mokee-gateway-web/src/views/admin/system/SystemForm.vue
Normal file
201
mokee-gateway-web/src/views/admin/system/SystemForm.vue
Normal 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>
|
||||
272
mokee-gateway-web/src/views/admin/system/SystemList.vue
Normal file
272
mokee-gateway-web/src/views/admin/system/SystemList.vue
Normal 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>
|
||||
537
mokee-gateway-web/src/views/admin/user/UserForm.vue
Normal file
537
mokee-gateway-web/src/views/admin/user/UserForm.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<!--
|
||||
用户表单页(新增/编辑)
|
||||
|
||||
功能:
|
||||
- 新增模式:基本信息 + 密码 + 系统绑定 + 角色分配(提交后一并保存绑定和角色)
|
||||
- 编辑模式:基本信息(用户名禁用)+ 管理员重置密码(仅 super_admin 可见)+ 系统绑定 + 角色分配
|
||||
|
||||
系统绑定流程:
|
||||
1. 加载所有系统列表(GET /zgapi/v1/admin/system/list)
|
||||
2. 编辑模式:加载用户已绑定的系统 ID(GET /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_admin,body: {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>
|
||||
279
mokee-gateway-web/src/views/admin/user/UserList.vue
Normal file
279
mokee-gateway-web/src/views/admin/user/UserList.vue
Normal 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>
|
||||
1399
mokee-gateway-web/src/views/docs/IntegrationDoc.vue
Normal file
1399
mokee-gateway-web/src/views/docs/IntegrationDoc.vue
Normal file
File diff suppressed because it is too large
Load Diff
322
mokee-gateway-web/src/views/login/LoginView.vue
Normal file
322
mokee-gateway-web/src/views/login/LoginView.vue
Normal 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">
|
||||
© 2026 Mokee Gateway |
|
||||
<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>
|
||||
356
mokee-gateway-web/src/views/select/SystemSelectView.vue
Normal file
356
mokee-gateway-web/src/views/select/SystemSelectView.vue
Normal 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>
|
||||
248
mokee-gateway-web/src/widgets/user-chip/UserChip.vue
Normal file
248
mokee-gateway-web/src/widgets/user-chip/UserChip.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<!--
|
||||
用户头像下拉 Widget 核心组件
|
||||
|
||||
功能:
|
||||
- 加载中 → 骨架屏
|
||||
- 未登录(无 Token 或 API 失败)→ "登录"按钮,点击跳转统一登录页
|
||||
- 已登录 → 头像 + 用户名 + 下拉菜单(仅两项:切换系统、退出登录)
|
||||
|
||||
头像处理:
|
||||
- 如果头像 URL 指向文件服务(file.wg.zgitm.com),每次组件挂载时获取新下载 Token
|
||||
- 数据库只存基础 URL(不带 ?token=xxx),Token 一次一密,每次加载重新获取
|
||||
|
||||
下拉菜单(仅两项,没有个人中心):
|
||||
- 🔄 切换系统 → 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) {
|
||||
// 头像来自文件服务 → 每次都获取新下载 Token(Token 一次一密)
|
||||
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. 通知服务端销毁 session(fire-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>
|
||||
139
mokee-gateway-web/src/widgets/user-chip/api.ts
Normal file
139
mokee-gateway-web/src/widgets/user-chip/api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
mokee-gateway-web/src/widgets/user-chip/cookie.ts
Normal file
42
mokee-gateway-web/src/widgets/user-chip/cookie.ts
Normal 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}`
|
||||
}
|
||||
92
mokee-gateway-web/src/widgets/user-chip/main.ts
Normal file
92
mokee-gateway-web/src/widgets/user-chip/main.ts
Normal 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(浏览器可直接执行)
|
||||
*
|
||||
* 注意:此文件无任何 export,IIFE 打包后直接执行。
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
||||
21
mokee-gateway-web/tsconfig.json
Normal file
21
mokee-gateway-web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
81
mokee-gateway-web/vite.config.ts
Normal file
81
mokee-gateway-web/vite.config.ts
Normal 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) {
|
||||
// 去掉所有 modulepreload(Element 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')) {
|
||||
// 基础组件保持独立小 chunk(login/select 页面按需加载),不打包进 el-admin
|
||||
if (EL_BASE.some(c => id.includes(`/${c}/`) || id.includes(`/${c}.`) || id.endsWith(`/${c}`))) return
|
||||
// 管理后台组件打成一个 el-admin chunk,进后台一次性加载,减少后续页面切换的网络请求
|
||||
return 'el-admin'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
43
mokee-gateway-web/vite.widget.config.ts
Normal file
43
mokee-gateway-web/vite.widget.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user