Files
mokeegateway/mokee-gateway-web/backup-on-demand/views-20260612-2236/docs/IntegrationDoc.vue
2026-07-15 16:13:27 +08:00

628 lines
25 KiB
Vue
Raw Blame History

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