Compare commits
3 Commits
v2026.07.0
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
bde6c77f11 | |
|
|
d62dcd2f82 | |
|
|
47661c31f1 |
|
|
@ -42,6 +42,9 @@ shared/
|
||||||
├── Table.tsx # 表格(styled thead,tbody 由调用方提供)
|
├── Table.tsx # 表格(styled thead,tbody 由调用方提供)
|
||||||
├── Toast.tsx # 通知条(右下角固定定位)
|
├── Toast.tsx # 通知条(右下角固定定位)
|
||||||
├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页")
|
├── Pagination.tsx # 分页(中文标签:"上一页"/"下一页")
|
||||||
|
├── hooks/ # React Hooks
|
||||||
|
│ ├── useDirtyTracker.ts # 脏状态追踪("修改后才显示保存按钮"模式)
|
||||||
|
│ └── index.ts # Barrel 导出
|
||||||
└── login-page.tsx # 统一登录页(SSO + LDAP 切换,不在 barrel 中导出)
|
└── login-page.tsx # 统一登录页(SSO + LDAP 切换,不在 barrel 中导出)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -350,6 +353,7 @@ import LoginPage from '@shared/ui/login-page' // 单独导入,不在 barrel
|
||||||
| Table | `headers: string[]`, `children: ReactNode` | 样式化 thead,tbody 由调用方提供 |
|
| Table | `headers: string[]`, `children: ReactNode` | 样式化 thead,tbody 由调用方提供 |
|
||||||
| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 |
|
| Toast | `message`, `type: 'success'|'error'|'info'`, `onClose` | 右下角固定通知条 |
|
||||||
| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签,totalPages <= 1 时隐藏 |
|
| Pagination | `page`, `totalPages`, `onPageChange` | 中文标签,totalPages <= 1 时隐藏 |
|
||||||
|
| useDirtyTracker | `isDirty(key, item): boolean`, `markClean(key, item): void`, `removeItem(key): void` | 脏状态追踪 Hook,"修改后才显示保存按钮"模式 |
|
||||||
| LoginPage | `siteName: string`, `title?: string` | 完整登录页,支持 SSO 和 LDAP 切换 |
|
| LoginPage | `siteName: string`, `title?: string` | 完整登录页,支持 SSO 和 LDAP 切换 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { useDirtyTracker } from './useDirtyTracker'
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
'use client'
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脏状态追踪 Hook — 用于"修改后才显示保存按钮"模式。
|
||||||
|
*
|
||||||
|
* @example 数组场景(每个子项独立保存)
|
||||||
|
* const { isDirty, markClean, removeItem } = useDirtyTracker()
|
||||||
|
* {items.map(item => (
|
||||||
|
* <div key={item.id}>
|
||||||
|
* <input onChange={...} />
|
||||||
|
* {isDirty(item.id, item) && <button onClick={save}>保存</button>}
|
||||||
|
* </div>
|
||||||
|
* ))}
|
||||||
|
* // 保存成功后: markClean(id, savedItem)
|
||||||
|
* // 删除时: removeItem(id)
|
||||||
|
*/
|
||||||
|
export function useDirtyTracker() {
|
||||||
|
const savedMap = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定 key 的当前数据是否相对于上次 markClean 有修改。
|
||||||
|
* 从未 markClean 过的 key 视为 dirty(适用于新增项)。
|
||||||
|
*/
|
||||||
|
const isDirty = (key: string, current: unknown): boolean => {
|
||||||
|
const saved = savedMap.current.get(key)
|
||||||
|
if (saved === undefined) return true // 从未保存过 → dirty
|
||||||
|
return saved !== JSON.stringify(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定 key 的当前数据标记为"已保存"(重置 dirty 状态)。
|
||||||
|
*/
|
||||||
|
const markClean = (key: string, item: unknown): void => {
|
||||||
|
savedMap.current.set(key, JSON.stringify(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定 key 的追踪记录(用于列表项被删除时清理)。
|
||||||
|
*/
|
||||||
|
const removeItem = (key: string): void => {
|
||||||
|
savedMap.current.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDirty, markClean, removeItem }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
// shared/ui/login-page.tsx — 统一登录页组件(与 issue-ai 登录页风格一致)
|
// shared/ui/login-page.tsx — 统一登录页组件
|
||||||
import { useState } from 'react'
|
// 支持 SSO、LDAP 和 localadmin(通过 ?method=local URL 参数激活)三种登录方式
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心"
|
siteName: string // 站点名称,如 "IT 工单跟踪系统"、"告警监控中心"
|
||||||
|
|
@ -14,6 +15,12 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
const [showLdapForm, setShowLdapForm] = useState(false)
|
||||||
|
const [showLocalAdmin, setShowLocalAdmin] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
if (params.get('method') === 'local') setShowLocalAdmin(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault(); setError(''); setLoading(true)
|
e.preventDefault(); setError(''); setLoading(true)
|
||||||
|
|
@ -47,7 +54,7 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
||||||
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
|
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showLdapForm ? (
|
{!showLdapForm && !showLocalAdmin ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleSsoLogin}
|
onClick={handleSsoLogin}
|
||||||
|
|
@ -73,6 +80,43 @@ export default function LoginPage({ siteName, title, redirectPath = '/' }: Login
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
) : showLocalAdmin ? (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||||
|
<input
|
||||||
|
type="text" value={username} onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit" disabled={loading}
|
||||||
|
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '本地管理员登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { window.location.href = '/login' }}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 underline"
|
||||||
|
>
|
||||||
|
返回普通登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue