feat: close provider promotion and support access gates
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 3m58s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 29m6s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 15m55s

This commit is contained in:
2026-08-11 15:21:14 +08:00
parent 9768491485
commit ff6ea2877e
77 changed files with 10281 additions and 588 deletions
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
@@ -0,0 +1 @@
@AGENTS.md
@@ -14,7 +14,7 @@
"dependencies": {
"@yuqei/design-system": "0.1.0",
"lucide-react": "1.21.0",
"next": "16.2.9",
"next": "16.3.0",
"react": "19.2.7",
"react-dom": "19.2.7"
},
@@ -6351,3 +6351,128 @@ body {
min-width: 36px;
}
}
.mobile-nav-toggle,
.mobile-nav-close,
.mobile-sidebar-actions,
.sidebar-scrim {
display: none;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: minmax(0, 1fr);
}
.app-sidebar {
position: fixed;
z-index: 60;
inset: 0 auto 0 0;
width: min(86vw, 320px);
height: 100dvh;
max-height: 100dvh;
transform: translateX(-105%);
visibility: hidden;
transition:
transform 180ms ease,
visibility 0s linear 180ms;
}
.app-sidebar-open {
transform: translateX(0);
visibility: visible;
transition-delay: 0s;
}
.sidebar-scrim-visible {
position: fixed;
z-index: 50;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 0;
background: rgba(13, 20, 18, 0.58);
cursor: default;
}
.mobile-nav-toggle,
.mobile-nav-close {
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba(53, 67, 62, 0.18);
border-radius: 8px;
background: rgba(255, 253, 248, 0.9);
color: var(--ink);
cursor: pointer;
}
.mobile-nav-toggle {
display: inline-flex;
flex: 0 0 auto;
}
.mobile-nav-close {
position: absolute;
z-index: 1;
top: 14px;
right: 14px;
display: inline-flex;
color: #fffdf8;
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.14);
}
.app-sidebar .brand-block {
padding-right: 52px;
}
.mobile-sidebar-actions {
display: grid;
gap: 8px;
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.mobile-sidebar-actions .workspace-switcher,
.mobile-sidebar-actions .workspace-switcher-stack,
.mobile-sidebar-actions .button {
width: 100%;
}
}
@media (max-width: 640px) {
.app-sidebar {
gap: 10px;
height: 100dvh;
max-height: 100dvh;
}
.app-sidebar .nav-group {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
margin: 0;
padding: 0;
overflow-x: visible;
}
.app-sidebar .nav-link {
justify-content: flex-start;
width: 100%;
min-width: 0;
padding: 7px 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.app-sidebar {
transition: none;
}
}
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useEffect, type ReactNode } from "react";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -14,12 +14,14 @@ import {
LayoutDashboard,
LibraryBig,
LogOut,
Menu,
MessageSquareText,
Scale,
Search,
Settings,
ShieldCheck,
UsersRound
UsersRound,
X
} from "lucide-react";
import { canAccessAdminPath, getRoleLabel, normalizeUserRole } from "@/lib/access-control";
import type { ContractWebSession } from "@/lib/contract-session";
@@ -75,6 +77,7 @@ const policyLinks = [
export function AppShell({ children, session }: { children: ReactNode; session: ContractWebSession | null }) {
const pathname = usePathname();
const router = useRouter();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const currentRole = normalizeUserRole(session?.role);
const visibleAssetNav = assetNav.filter(
(item) => !item.restricted
@@ -104,6 +107,28 @@ export function AppShell({ children, session }: { children: ReactNode; session:
return () => window.clearTimeout(prefetchTimer);
}, [pathname, router, session]);
useEffect(() => {
setIsMobileNavOpen(false);
}, [pathname]);
useEffect(() => {
if (!isMobileNavOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsMobileNavOpen(false);
}
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
};
}, [isMobileNavOpen]);
async function logout() {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
router.push("/login");
@@ -137,7 +162,26 @@ export function AppShell({ children, session }: { children: ReactNode; session:
return (
<ContractSessionContext.Provider value={session}>
<div className="app-shell">
<aside className="app-sidebar">
<button
aria-label="关闭导航"
className={`sidebar-scrim ${isMobileNavOpen ? "sidebar-scrim-visible" : ""}`}
onClick={() => setIsMobileNavOpen(false)}
type="button"
/>
<aside
aria-label="主导航"
className={`app-sidebar ${isMobileNavOpen ? "app-sidebar-open" : ""}`}
id="app-sidebar"
>
<button
aria-label="关闭导航"
className="mobile-nav-close"
onClick={() => setIsMobileNavOpen(false)}
title="关闭导航"
type="button"
>
<X size={20} />
</button>
<Link className="brand-block" href="/workspace">
<span className="brand-mark" aria-hidden="true">YQ</span>
<span className="brand-copy">
@@ -157,6 +201,14 @@ export function AppShell({ children, session }: { children: ReactNode; session:
</>
)}
<div className="mobile-sidebar-actions">
<WorkspaceSwitcher session={session} />
<button className="button button-soft" onClick={() => void logout()} type="button">
<LogOut size={16} />
退出
</button>
</div>
<div className="app-sidebar-footer">
<span>{getRoleLabel(currentRole)}</span>
<strong>AI 审核 / 留痕 / 归档</strong>
@@ -166,6 +218,17 @@ export function AppShell({ children, session }: { children: ReactNode; session:
<main className="app-main">
<header className="topbar">
<button
aria-controls="app-sidebar"
aria-expanded={isMobileNavOpen}
aria-label="打开导航"
className="mobile-nav-toggle"
onClick={() => setIsMobileNavOpen(true)}
title="打开导航"
type="button"
>
<Menu size={20} />
</button>
<div className="topbar-heading">
<span className="topbar-kicker">流程运营中枢</span>
<div className="topbar-title">{resolveTitle(pathname)}</div>