feat: initialize V2 contract web shell

This commit is contained in:
2026-06-22 18:45:39 +08:00
parent a6157fcaf8
commit dfb514e0cf
31 changed files with 2686 additions and 2 deletions
+1
View File
@@ -1,4 +1,5 @@
backend/.venv/
node_modules/
frontend/.next/
frontend/.next-dev-log/
frontend/node_modules/
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_CONTRACT_API_BASE_URL=http://localhost:8100
-1
View File
@@ -1 +0,0 @@
+19
View File
@@ -0,0 +1,19 @@
# Contract Web
Next.js app for the V2 contract platform.
## Local Run
```powershell
npm install
npm run web:dev
```
Default URL: `http://localhost:3000`
## Validate
```powershell
npm run web:typecheck
npm run web:build
```
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@yuqei/design-system"]
};
export default nextConfig;
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@yuqei/contract-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@yuqei/design-system": "0.1.0",
"lucide-react": "1.21.0",
"next": "16.2.9",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@types/node": "26.0.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
const adminSections = [
{
title: "企业权限",
description: "组织、角色、合同可见范围和操作边界。",
icon: ShieldCheck
},
{
title: "AI 参数",
description: "模型连接、运行参数、提示词版本和审计策略。",
icon: SlidersHorizontal
},
{
title: "系统设置",
description: "文件存储、通知、备份、健康检查和部署配置。",
icon: Settings
}
];
export default function AdminPage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
</div>
<div className="grid-two">
{adminSections.map((section) => {
const Icon = section.icon;
return (
<section className="panel" key={section.title}>
<div className="panel-body list">
<Icon size={22} />
<h2 className="panel-title">{section.title}</h2>
<p className="muted">{section.description}</p>
</div>
</section>
);
})}
</div>
</>
);
}
@@ -0,0 +1,146 @@
import Link from "next/link";
import { Archive, Download, FilePenLine, GitCompare, Send } from "lucide-react";
import { contracts } from "@/lib/mock-data";
type PageProps = {
params: Promise<{
id: string;
}>;
};
export default async function ContractDetailPage({ params }: PageProps) {
const { id } = await params;
const contract = contracts.find((item) => item.id === id) ?? contracts[0];
return (
<>
<div className="page-heading">
<div>
<h1>{contract.title}</h1>
<p>{contract.code} · {contract.counterparty} · {contract.updatedAt}</p>
</div>
<Link className="button" href="/contracts">
</Link>
</div>
<div className="next-action">
<div>
<strong>{contract.nextAction}</strong>
<span className="muted">线</span>
</div>
<div className="topbar-actions">
<button className="button button-primary">
<Send size={16} />
</button>
<button className="button">
<FilePenLine size={16} />
</button>
<button className="button icon-button" title="下载">
<Download size={16} />
</button>
</div>
</div>
<div className="toolbar" style={{ marginTop: 16 }}>
<a className="tag tag-primary" href="#overview"></a>
<a className="tag" href="#preview"></a>
<a className="tag" href="#approval"></a>
<a className="tag" href="#versions"></a>
<a className="tag" href="#audit"></a>
</div>
<div className="detail-grid">
<main className="list">
<section className="panel" id="overview">
<div className="panel-header">
<h2 className="panel-title"></h2>
<span className={`tag ${contract.statusTone}`}>{contract.status}</span>
</div>
<div className="panel-body grid-two">
<div>
<div className="item-meta"></div>
<strong>{contract.amount}</strong>
</div>
<div>
<div className="item-meta"></div>
<strong>{contract.owner}</strong>
</div>
<div>
<div className="item-meta"></div>
<strong>{contract.counterparty}</strong>
</div>
<div>
<div className="item-meta"></div>
<strong>{contract.nextAction}</strong>
</div>
</div>
</section>
<section className="panel" id="preview">
<div className="panel-header">
<h2 className="panel-title"></h2>
<button className="button">
<GitCompare size={16} />
</button>
</div>
<div className="panel-body">
<article className="document-preview">
<h2>{contract.title}</h2>
<p></p>
<p></p>
</article>
</div>
</section>
</main>
<aside className="list">
<details className="panel" id="approval" open>
<summary className="panel-header">
<span className="panel-title">线</span>
</summary>
<div className="panel-body list">
<div className="list-item">
<strong></strong>
<span className="item-meta"> · 09:30</span>
</div>
<div className="list-item">
<strong></strong>
<span className="item-meta"> · </span>
</div>
</div>
</details>
<details className="panel" id="versions">
<summary className="panel-header">
<span className="panel-title"></span>
</summary>
<div className="panel-body list">
<button className="button">
<GitCompare size={16} />
</button>
<button className="button">
<Archive size={16} />
</button>
</div>
</details>
<details className="panel" id="audit">
<summary className="panel-header">
<span className="panel-title"></span>
</summary>
<div className="panel-body list">
<div className="item-meta">线</div>
<div className="item-meta"></div>
</div>
</details>
</aside>
</div>
</>
);
}
@@ -0,0 +1,64 @@
import Link from "next/link";
import { Download, FilePlus2, Search } from "lucide-react";
import { contracts } from "@/lib/mock-data";
export default function ContractsPage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
<Link className="button button-primary" href="/workspace">
<FilePlus2 size={17} />
</Link>
</div>
<div className="toolbar">
<input className="search-input" placeholder="搜索合同名称、相对方、编号" />
<button className="button button-soft">
<Search size={16} />
</button>
</div>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{contracts.map((contract) => (
<tr key={contract.id}>
<td>
<Link className="item-title" href={`/contracts/${contract.id}`}>
{contract.title}
</Link>
<div className="item-meta">{contract.code}</div>
</td>
<td>{contract.counterparty}</td>
<td>
<span className={`tag ${contract.statusTone}`}>{contract.status}</span>
</td>
<td>{contract.nextAction}</td>
<td>{contract.updatedAt}</td>
<td>
<button className="button icon-button" title="下载">
<Download size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { Download, Eye, Filter } from "lucide-react";
import { files } from "@/lib/mock-data";
export default function FilesPage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
<button className="button button-primary">
<Download size={16} />
</button>
</div>
<div className="toolbar">
<input className="search-input" placeholder="搜索文件名、合同、上传人" />
<button className="button">
<Filter size={16} />
</button>
</div>
<table className="table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id}>
<td>
<strong>{file.name}</strong>
<div className="item-meta">{file.size}</div>
</td>
<td>
<Link className="item-title" href={`/contracts/${file.contractId}`}>
{file.contractTitle}
</Link>
</td>
<td><span className="tag">{file.type}</span></td>
<td>{file.uploadedAt}</td>
<td>
<div className="topbar-actions">
<button className="button icon-button" title="预览">
<Eye size={16} />
</button>
<button className="button icon-button" title="下载">
<Download size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
+504
View File
@@ -0,0 +1,504 @@
:root {
color-scheme: light;
--ink: #172033;
--muted: #667085;
--line: #d9e0ea;
--canvas: #f6f8fb;
--surface: #ffffff;
--primary: #2457d6;
--primary-soft: #eaf0ff;
--success: #16794c;
--success-soft: #e7f6ef;
--warning: #b35b00;
--warning-soft: #fff3df;
--danger: #bd2d2d;
--danger-soft: #fdecec;
--accent: #0b7a75;
--accent-soft: #e4f5f3;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
color: var(--ink);
background: var(--canvas);
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.app-sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 16px;
color: #f8fbff;
background: #16233f;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-block {
display: grid;
gap: 4px;
padding: 4px 8px 18px;
}
.brand-title {
font-size: 18px;
font-weight: 750;
letter-spacing: 0;
}
.brand-subtitle {
color: #bac6d9;
font-size: 12px;
}
.nav-group {
display: grid;
gap: 6px;
margin-top: 12px;
}
.nav-group-title {
margin: 18px 8px 8px;
color: #91a1bc;
font-size: 12px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border-radius: 8px;
color: #d8e2f2;
}
.nav-link svg {
flex: 0 0 auto;
}
.nav-link:hover,
.nav-link-active {
color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
.app-main {
display: grid;
grid-template-rows: 64px minmax(0, 1fr);
min-width: 0;
}
.topbar {
position: sticky;
z-index: 10;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
height: 64px;
padding: 0 28px;
background: rgba(246, 248, 251, 0.92);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
}
.topbar-title {
overflow: hidden;
font-size: 16px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.content {
width: min(100%, 1440px);
margin: 0 auto;
padding: 24px 28px 40px;
}
.page-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.page-heading h1 {
margin: 0;
font-size: 28px;
line-height: 1.2;
letter-spacing: 0;
}
.page-heading p {
margin: 6px 0 0;
color: var(--muted);
line-height: 1.6;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--ink);
background: var(--surface);
cursor: pointer;
}
.button-primary {
color: #ffffff;
border-color: var(--primary);
background: var(--primary);
}
.button-soft {
color: var(--primary);
border-color: transparent;
background: var(--primary-soft);
}
.icon-button {
width: 36px;
min-width: 36px;
padding: 0;
}
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 750;
}
.panel-body {
padding: 18px;
}
.split-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.7fr);
gap: 16px;
}
.metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
padding: 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.metric-value {
font-size: 24px;
font-weight: 800;
}
.metric-label {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
.list {
display: grid;
gap: 10px;
}
.list-item {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.list-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.item-title {
min-width: 0;
font-weight: 700;
}
.item-title svg {
vertical-align: -3px;
}
.item-meta {
color: var(--muted);
font-size: 13px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.search-input {
min-width: min(100%, 360px);
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.tag {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
color: var(--muted);
background: #edf1f7;
font-size: 12px;
white-space: nowrap;
}
.tag-success {
color: var(--success);
background: var(--success-soft);
}
.tag-warning {
color: var(--warning);
background: var(--warning-soft);
}
.tag-primary {
color: var(--primary);
background: var(--primary-soft);
}
.tag-danger {
color: var(--danger);
background: var(--danger-soft);
}
.flow-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.flow-step {
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfe;
}
.flow-step-title {
font-weight: 750;
}
.flow-step p {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.next-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
border: 1px solid #bfd0ff;
border-radius: 8px;
background: #f5f8ff;
}
.next-action strong {
display: block;
margin-bottom: 4px;
}
.muted {
color: var(--muted);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
margin-top: 16px;
}
.document-preview {
min-height: 420px;
padding: 28px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
line-height: 1.8;
}
.document-preview h2 {
margin: 0 0 16px;
font-size: 22px;
text-align: center;
}
.table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.table th,
.table td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
.table th {
color: var(--muted);
font-size: 13px;
font-weight: 650;
background: #f8fafc;
}
.table tr:last-child td {
border-bottom: 0;
}
details.panel summary {
cursor: pointer;
list-style: none;
}
details.panel summary::-webkit-details-marker {
display: none;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.app-sidebar {
position: static;
height: auto;
}
.nav-group {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.split-layout,
.detail-grid,
.grid-two {
grid-template-columns: 1fr;
}
.metric-row,
.flow-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.content,
.topbar {
padding-left: 16px;
padding-right: 16px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.metric-row,
.flow-strip,
.nav-group {
grid-template-columns: 1fr;
}
.topbar-actions {
display: none;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { SystemHealthPanel } from "@/features/system/system-health-panel";
export default function HealthPage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p>线 APIAI </p>
</div>
</div>
<SystemHealthPanel />
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { AppShell } from "@/components/app-shell";
import "./globals.css";
export const metadata: Metadata = {
title: "钰麒合同 V2",
description: "企业合同智能起草、审查、协同与归档平台"
};
export default function RootLayout({
children
}: Readonly<{
children: ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>
<AppShell>{children}</AppShell>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { WorkspacePage } from "@/features/workspace/workspace-page";
export default function Home() {
return <WorkspacePage />;
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { Search } from "lucide-react";
import { searchGroups } from "@/lib/mock-data";
export default function SearchPage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
</div>
<div className="toolbar">
<input className="search-input" placeholder="搜索合同、条款、相对方、文件" />
<button className="button button-primary">
<Search size={16} />
</button>
</div>
<div className="list">
{searchGroups.map((group) => (
<section className="panel" key={group.title}>
<div className="panel-header">
<h2 className="panel-title">{group.title}</h2>
<span className="tag">{group.items.length} </span>
</div>
<div className="panel-body list">
{group.items.map((item) => (
<Link className="list-item" href={item.href} key={item.title}>
<div className="list-item-header">
<span className="item-title">{item.title}</span>
<span className="item-meta">{item.updatedAt}</span>
</div>
<span className="item-meta">{item.summary}</span>
</Link>
))}
</div>
</section>
))}
</div>
</>
);
}
@@ -0,0 +1,5 @@
import { WorkspacePage } from "@/features/workspace/workspace-page";
export default function Page() {
return <WorkspacePage />;
}
@@ -0,0 +1,97 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Activity,
Archive,
FileSearch,
Files,
Home,
LayoutDashboard,
Search,
Settings
} from "lucide-react";
const mainNav = [
{ href: "/workspace", label: "工作台", icon: Home },
{ href: "/contracts", label: "合同", icon: FileSearch },
{ href: "/search", label: "统一搜索", icon: Search },
{ href: "/files", label: "合同仓库", icon: Files }
];
const adminNav = [
{ href: "/admin", label: "管理区", icon: Settings },
{ href: "/health", label: "系统健康", icon: Activity }
];
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<div className="app-shell">
<aside className="app-sidebar">
<Link className="brand-block" href="/workspace">
<span className="brand-title"></span>
<span className="brand-subtitle">AI Contract Platform V2</span>
</Link>
<div className="nav-group-title"></div>
<nav className="nav-group" aria-label="主流程导航">
{mainNav.map((item) => {
const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link className={`nav-link ${active ? "nav-link-active" : ""}`} href={item.href} key={item.href}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="nav-group-title"></div>
<nav className="nav-group" aria-label="管理导航">
{adminNav.map((item) => {
const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link className={`nav-link ${active ? "nav-link-active" : ""}`} href={item.href} key={item.href}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
<main className="app-main">
<header className="topbar">
<div className="topbar-title">{resolveTitle(pathname)}</div>
<div className="topbar-actions">
<Link className="button button-soft" href="/workspace">
<LayoutDashboard size={16} />
</Link>
<Link className="button button-primary" href="/contracts">
<Archive size={16} />
</Link>
</div>
</header>
<div className="content">{children}</div>
</main>
</div>
);
}
function resolveTitle(pathname: string): string {
if (pathname.startsWith("/contracts/")) return "合同详情";
if (pathname.startsWith("/contracts")) return "合同主流程";
if (pathname.startsWith("/search")) return "统一搜索";
if (pathname.startsWith("/files")) return "合同仓库";
if (pathname.startsWith("/admin")) return "管理区";
if (pathname.startsWith("/health")) return "系统健康";
return "工作台";
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { Activity, RefreshCcw } from "lucide-react";
type HealthState =
| { status: "checking" }
| { status: "ok"; service: string; version: string; environment: string }
| { status: "error"; message: string };
const apiBaseUrl = process.env.NEXT_PUBLIC_CONTRACT_API_BASE_URL ?? "http://localhost:8100";
export function SystemHealthPanel() {
const [health, setHealth] = useState<HealthState>({ status: "checking" });
async function checkHealth() {
setHealth({ status: "checking" });
try {
const response = await fetch(`${apiBaseUrl}/health`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json() as { service: string; version: string; environment: string };
setHealth({ status: "ok", ...data });
} catch (error) {
setHealth({
status: "error",
message: error instanceof Error ? error.message : "unknown error"
});
}
}
useEffect(() => {
void checkHealth();
}, []);
return (
<section className="panel">
<div className="panel-header">
<h2 className="panel-title"> API</h2>
<button className="button" onClick={() => void checkHealth()}>
<RefreshCcw size={16} />
</button>
</div>
<div className="panel-body list">
<div className="list-item">
<div className="list-item-header">
<span className="item-title">
<Activity size={16} /> {apiBaseUrl}
</span>
{health.status === "ok" && <span className="tag tag-success"></span>}
{health.status === "checking" && <span className="tag tag-primary"></span>}
{health.status === "error" && <span className="tag tag-danger"></span>}
</div>
{health.status === "ok" && (
<span className="item-meta">
{health.service} · {health.version} · {health.environment}
</span>
)}
{health.status === "error" && (
<span className="item-meta">
{health.message}
</span>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,103 @@
import Link from "next/link";
import { FilePlus2, Gavel, Inbox, Search, UploadCloud } from "lucide-react";
import { mainFlow } from "@yuqei/design-system";
import { tasks } from "@/lib/mock-data";
export function WorkspacePage() {
return (
<>
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
<div className="topbar-actions">
<Link className="button button-primary" href="/contracts">
<FilePlus2 size={17} />
</Link>
<Link className="button" href="/search">
<Search size={17} />
</Link>
</div>
</div>
<div className="metric-row">
<div className="metric">
<div className="metric-value">6</div>
<div className="metric-label"></div>
</div>
<div className="metric">
<div className="metric-value">12</div>
<div className="metric-label"></div>
</div>
<div className="metric">
<div className="metric-value">3</div>
<div className="metric-label"></div>
</div>
<div className="metric">
<div className="metric-value">98%</div>
<div className="metric-label"></div>
</div>
</div>
<div className="split-layout">
<section className="panel">
<div className="panel-header">
<h2 className="panel-title"></h2>
<span className="tag tag-primary"></span>
</div>
<div className="panel-body list">
{tasks.map((task) => (
<Link className="list-item" href={task.href} key={task.id}>
<div className="list-item-header">
<span className="item-title">{task.title}</span>
<span className={`tag ${task.tone}`}>{task.type}</span>
</div>
<span className="item-meta">{task.summary}</span>
</Link>
))}
</div>
</section>
<aside className="list">
<section className="panel">
<div className="panel-header">
<h2 className="panel-title"></h2>
</div>
<div className="panel-body list">
<Link className="button button-primary" href="/contracts">
<FilePlus2 size={17} />
</Link>
<Link className="button" href="/search">
<Gavel size={17} />
</Link>
<Link className="button" href="/files">
<UploadCloud size={17} />
</Link>
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2 className="panel-title"></h2>
<Inbox size={18} />
</div>
<div className="panel-body flow-strip">
{mainFlow.map((step) => (
<div className="flow-step" key={step.key}>
<div className="flow-step-title">{step.title}</div>
<p>{step.description}</p>
</div>
))}
</div>
</section>
</aside>
</div>
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
export const contracts = [
{
id: "contract-001",
title: "技术服务合同",
code: "HT-2026-0001",
counterparty: "北京辰星科技有限公司",
status: "审查中",
statusTone: "tag-warning",
nextAction: "法务确认付款和验收条款",
updatedAt: "今天 10:18",
amount: "¥ 280,000",
owner: "张敏"
},
{
id: "contract-002",
title: "房屋租赁合同",
code: "HT-2026-0002",
counterparty: "内蒙古青禾置业",
status: "待提交",
statusTone: "tag-primary",
nextAction: "补充租赁期限后提交",
updatedAt: "昨天 18:42",
amount: "¥ 96,000",
owner: "王磊"
},
{
id: "contract-003",
title: "采购框架协议",
code: "HT-2026-0003",
counterparty: "上海景云供应链",
status: "已归档",
statusTone: "tag-success",
nextAction: "跟踪履约节点",
updatedAt: "2026-06-20",
amount: "¥ 1,200,000",
owner: "李娜"
}
] as const;
export const tasks = [
{
id: "task-001",
title: "技术服务合同需要法务审查",
type: "审查",
tone: "tag-warning",
summary: "付款节点、验收标准和违约责任需要形成处理意见。",
href: "/contracts/contract-001"
},
{
id: "task-002",
title: "房屋租赁合同待补充期限",
type: "起草",
tone: "tag-primary",
summary: "补齐租赁期限、保证金和交付条件后即可提交。",
href: "/contracts/contract-002"
},
{
id: "task-003",
title: "采购框架协议履约节点到期",
type: "履约",
tone: "tag-success",
summary: "本周需要上传首批交付验收证据。",
href: "/contracts/contract-003"
}
] as const;
export const searchGroups = [
{
title: "合同",
items: contracts.map((contract) => ({
title: contract.title,
summary: `${contract.counterparty} · ${contract.nextAction}`,
updatedAt: contract.updatedAt,
href: `/contracts/${contract.id}`
}))
},
{
title: "审查意见",
items: [
{
title: "付款条款风险意见",
summary: "建议明确验收通过后的付款期限和逾期责任。",
updatedAt: "今天 09:50",
href: "/contracts/contract-001"
}
]
},
{
title: "文件",
items: [
{
title: "技术服务合同-审查版.docx",
summary: "来自合同仓库,已关联技术服务合同。",
updatedAt: "今天 10:21",
href: "/files"
}
]
}
] as const;
export const files = [
{
id: "file-001",
name: "技术服务合同-审查版.docx",
contractId: "contract-001",
contractTitle: "技术服务合同",
type: "审查版",
size: "128 KB",
uploadedAt: "今天 10:21"
},
{
id: "file-002",
name: "房屋租赁合同-草稿.pdf",
contractId: "contract-002",
contractTitle: "房屋租赁合同",
type: "草稿",
size: "364 KB",
uploadedAt: "昨天 19:03"
}
] as const;
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}
+1058
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "yuqei-ai-contract-platform-v2",
"version": "0.1.0",
"private": true,
"packageManager": "npm@10.9.7",
"workspaces": [
"apps/*",
"packages/*",
"sdks/*"
],
"scripts": {
"test": "python -m pytest",
"web:dev": "npm --workspace @yuqei/contract-web run dev",
"web:build": "npm --workspace @yuqei/contract-web run build",
"web:typecheck": "npm --workspace @yuqei/contract-web run typecheck"
},
"overrides": {
"postcss": "8.5.15"
},
"devDependencies": {
"playwright": "^1.61.0"
}
}
-1
View File
@@ -1 +0,0 @@
+5
View File
@@ -0,0 +1,5 @@
# Yuqei Design System
Shared V2 design tokens and workflow primitives.
This package starts intentionally small. It holds product language, layout constants, tokens, and workflow metadata before it grows into a component library.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@yuqei/design-system",
"version": "0.1.0",
"private": true,
"type": "module",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"files": [
"src"
]
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./tokens";
export * from "./workflow";
+33
View File
@@ -0,0 +1,33 @@
export const brandTokens = {
color: {
ink: "#172033",
muted: "#667085",
line: "#d9e0ea",
canvas: "#f6f8fb",
surface: "#ffffff",
primary: "#2457d6",
primarySoft: "#eaf0ff",
success: "#16794c",
successSoft: "#e7f6ef",
warning: "#b35b00",
warningSoft: "#fff3df",
danger: "#bd2d2d",
dangerSoft: "#fdecec",
accent: "#0b7a75",
accentSoft: "#e4f5f3"
},
radius: {
sm: "6px",
md: "8px"
},
shadow: {
panel: "0 10px 30px rgba(16, 24, 40, 0.08)"
},
layout: {
sidebarWidth: "248px",
topbarHeight: "64px",
contentMaxWidth: "1440px"
}
} as const;
export type BrandTokens = typeof brandTokens;
+24
View File
@@ -0,0 +1,24 @@
export const mainFlow = [
{
key: "draft",
title: "合同起草",
description: "从模板或智能问答快速生成合同初稿。"
},
{
key: "review",
title: "法律审查",
description: "审查风险、补齐依据、形成处理意见。"
},
{
key: "approve",
title: "协同审批",
description: "按条件触发审批,不默认压迫所有用户。"
},
{
key: "archive",
title: "归档履约",
description: "沉淀文件、审计、版本和履约证据。"
}
] as const;
export type MainFlowStep = (typeof mainFlow)[number];
@@ -0,0 +1,118 @@
# V2 前端基础工程与轻量化体验底座记录
日期:2026-06-22
## 1. 本轮目标
承接 T0-5,建立 V2 前端基础工程和设计系统底座,同时把企业主线的轻量化体验原则落到第一版页面结构里。
## 2. 已完成内容
### Workspace
已建立根 npm workspace
- `package.json`
- `package-lock.json`
- `tsconfig.base.json`
根命令:
```powershell
npm run web:dev
npm run web:typecheck
npm run web:build
```
### Design System
目录:`packages/design-system`
已建立:
- 品牌颜色、布局、圆角、阴影 tokens。
- 企业主流程 `mainFlow` 元数据。
- package export 边界。
### Contract Web
目录:`apps/contract-web`
已建立:
- Next.js App Router 工程。
- 全局布局和左侧主导航。
- 工作台。
- 合同列表。
- 合同详情。
- 统一搜索。
- 合同仓库。
- 管理区。
- 系统健康页。
## 3. 体验原则落地
本轮页面遵循以下原则:
- 工作台只突出“待我处理”和“快速发起”。
- 主流程导航只保留:工作台、合同、统一搜索、合同仓库。
- 管理员能力集中到“管理区”。
- 合同详情首屏突出“下一步操作”。
- 审批、版本、履约、审计在合同详情侧栏折叠呈现。
- 统一搜索结果标题就是主入口,减少重复“打开”按钮。
- 合同仓库保留文件预览、关联合同和批量下载入口。
## 4. 验证结果
已通过:
```powershell
npm audit --audit-level=moderate
npm run web:typecheck
npm run web:build
python -m pytest
```
结果:
- npm audit0 vulnerabilities。
- TypeScript:通过。
- Next.js production build:通过。
- 后端 pytest6 passed。
页面验收:
- `/`
- `/workspace`
- `/contracts`
- `/contracts/contract-001`
- `/search`
- `/files`
- `/admin`
- `/health`
截图输出:
```text
output/playwright/v2-front-foundation/
```
## 5. 当前边界
本轮仍使用 mock 数据,不包含:
- 登录认证。
- 真实合同 API 对接。
- 真实文件上传/下载。
- 真实搜索查询。
- 真实审批提交。
- AI 问答和审查调用。
## 6. 下一步建议
继续进入 S2/S3 的最小闭环:
1. 建立 AI Platform MVP 的数据模型和 `/api/v1/legal/qa` 假实现。
2. 建立 Python SDK MVP,使 `contract-api` 只能通过 SDK 调用 `ai-platform-api`
3. 建立 Contract API 的工作台和合同列表真实接口。
4. 前端从 mock 数据切换到 Contract API。
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
}
}