feat(p1): complete P1 foundation stage
- monorepo: pnpm workspace + go.work + pyproject.toml + commitlint/husky - infra: docker-compose (minimal + full profiles) + init-sql + prometheus - arch-scan: multi-language scanner skeleton (TS/Go/Python/Proto) - shared-proto: buf v2 + classes.proto (ClassService CRUD contract) - api-gateway: Go/Gin + JWT HS256 auth + reverse proxy + request ID - classes: NestJS golden template (error system + observability + middleware + CRUD + tests) - teacher-portal: Next.js + paper-feel UI design system - CI/CD: 4 workflows (go/ts/py/proto) - docs: migration guide + project_rules + coding-standards + git-workflow + ui-design-system + 004 + 9 module READMEs + known-issues + spec/plan migration + roadmap
This commit is contained in:
2
apps/teacher-portal/next-env.d.ts
vendored
Normal file
2
apps/teacher-portal/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
14
apps/teacher-portal/next.config.js
Normal file
14
apps/teacher-portal/next.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/v1/:path*',
|
||||
destination: `${process.env.API_GATEWAY_URL || 'http://localhost:8080'}/api/v1/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
26
apps/teacher-portal/package.json
Normal file
26
apps/teacher-portal/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@edu/teacher-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
6
apps/teacher-portal/postcss.config.js
Normal file
6
apps/teacher-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
43
apps/teacher-portal/src/app/globals.css
Normal file
43
apps/teacher-portal/src/app/globals.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-paper: hsl(40, 20%, 98%);
|
||||
--color-ink: hsl(25, 3%, 15%);
|
||||
--color-ink-muted: hsl(30, 5%, 45%);
|
||||
--color-accent: hsl(220, 60%, 35%);
|
||||
--color-rule: hsl(30, 10%, 90%);
|
||||
--font-serif: 'Fraunces', Georgia, serif;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 纸感分隔线 */
|
||||
.rule {
|
||||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
.rule-thin {
|
||||
border-top: 2px solid var(--color-rule);
|
||||
}
|
||||
|
||||
/* 左侧竖线标记(节点展开样式)*/
|
||||
.mark-left {
|
||||
border-left: 2px solid var(--color-rule);
|
||||
padding-left: 12px;
|
||||
}
|
||||
24
apps/teacher-portal/src/app/layout.tsx
Normal file
24
apps/teacher-portal/src/app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter, Fraunces, JetBrains_Mono } from 'next/font/google';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
||||
const fraunces = Fraunces({ subsets: ['latin'], variable: '--font-fraunces' });
|
||||
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Edu Teacher Portal',
|
||||
description: 'K12 智慧教务平台 - 教师端',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
219
apps/teacher-portal/src/app/page.tsx
Normal file
219
apps/teacher-portal/src/app/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
name: string;
|
||||
gradeId: string;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [gradeId, setGradeId] = useState('550e8400-e29b-41d4-a716-446655440000');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchClasses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/v1/classes');
|
||||
const json: ApiResponse<ClassItem[]> = await res.json();
|
||||
if (json.success && json.data) {
|
||||
setClasses(json.data);
|
||||
} else {
|
||||
setError(json.error?.message || 'Failed to load');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, [fetchClasses]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/classes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer dev-token' },
|
||||
body: JSON.stringify({ name, gradeId, description }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
setError(json.error?.message || 'Create failed');
|
||||
return;
|
||||
}
|
||||
setName('');
|
||||
setDescription('');
|
||||
await fetchClasses();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/classes/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer dev-token' },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
setError(json.error?.message || 'Delete failed');
|
||||
return;
|
||||
}
|
||||
await fetchClasses();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-paper)' }}>
|
||||
<header className="border-b" style={{ borderColor: 'var(--color-rule)' }}>
|
||||
<div className="max-w-6xl mx-auto px-8 py-6">
|
||||
<h1 className="text-3xl" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}>
|
||||
班级管理
|
||||
</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
P1 黄金模板验证 - classes 域 CRUD
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8">
|
||||
{/* 左侧:创建表单 */}
|
||||
<aside className="col-span-4">
|
||||
<h2 className="text-xl mb-4" style={{ fontFamily: 'var(--font-serif)' }}>新建班级</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
班级名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
|
||||
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
|
||||
placeholder="如:高三(1)班"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
年级 ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
|
||||
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b resize-none"
|
||||
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
|
||||
style={{ background: 'var(--color-accent)', borderRadius: '6px' }}
|
||||
>
|
||||
创建班级
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
{/* 中间:班级列表(纸面)*/}
|
||||
<section className="col-span-8">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl" style={{ fontFamily: 'var(--font-serif)' }}>
|
||||
班级列表
|
||||
<span className="ml-2 text-sm font-sans" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
{classes.length} 个
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchClasses}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-4 py-2" style={{ borderColor: 'var(--color-accent)' }}>
|
||||
<p className="text-sm" style={{ color: 'var(--color-accent)' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm" style={{ color: 'var(--color-ink-muted)' }}>加载中...</p>
|
||||
) : classes.length === 0 ? (
|
||||
<p className="text-sm italic" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
暂无班级,从左侧创建第一个
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{classes.map((cls) => (
|
||||
<li key={cls.id} className="py-4 grid grid-cols-12 gap-4 items-baseline" style={{ borderBottom: '1px solid var(--color-rule)' }}>
|
||||
<div className="col-span-7">
|
||||
<h3 className="text-lg" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}>
|
||||
{cls.name}
|
||||
</h3>
|
||||
{cls.description && (
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}>{cls.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 text-xs font-mono" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
{cls.id.slice(0, 8)}...
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(cls.id)}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{ color: 'var(--color-ink-muted)' }}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/teacher-portal/tailwind.config.js
Normal file
26
apps/teacher-portal/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
paper: 'hsl(40, 20%, 98%)',
|
||||
ink: {
|
||||
DEFAULT: 'hsl(25, 3%, 15%)',
|
||||
muted: 'hsl(30, 5%, 45%)',
|
||||
},
|
||||
accent: 'hsl(220, 60%, 35%)',
|
||||
rule: 'hsl(30, 10%, 90%)',
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['Fraunces', 'Georgia', 'serif'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: '6px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
17
apps/teacher-portal/tsconfig.json
Normal file
17
apps/teacher-portal/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user