初始化

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

View File

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