refactor: share console page header

This commit is contained in:
boos
2026-06-06 20:18:28 +08:00
parent 823ac3c25d
commit 44dc89a0dd
11 changed files with 164 additions and 119 deletions
-3
View File
@@ -405,9 +405,6 @@ CREATE TABLE IF NOT EXISTS proxy_logs (
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
content_summary TEXT,
request_content TEXT,
response_content TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -1,3 +1,3 @@
-- No-op migration.
-- The proxy log content columns are present in the baseline schema and are
-- also protected by src/db/schema_compat.rs for older SQLite databases.
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
-3
View File
@@ -405,9 +405,6 @@ CREATE TABLE IF NOT EXISTS proxy_logs (
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
content_summary TEXT,
request_content TEXT,
response_content TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
+32
View File
@@ -0,0 +1,32 @@
import type { CSSProperties, ReactNode } from 'react';
type PageHeaderProps = {
title: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
className?: string;
style?: CSSProperties;
titleAs?: 'h1' | 'h2';
};
export default function PageHeader({
title,
subtitle,
actions,
className = '',
style,
titleAs = 'h2',
}: PageHeaderProps) {
const Heading = titleAs;
const classes = ['page-header', className].filter(Boolean).join(' ');
return (
<div className={classes} style={style}>
<div className="page-header-copy">
<Heading className="page-title">{title}</Heading>
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</div>
);
}
+10
View File
@@ -2926,12 +2926,22 @@ textarea {
letter-spacing: -0.01em;
}
.page-header-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-text-muted);
}
.page-header-copy .page-subtitle {
margin: 0;
}
.page-actions {
display: inline-flex;
align-items: center;
+8 -6
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { api, type RuntimeSettingsPayload } from '../api.js';
import PageHeader from '../components/PageHeader.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
@@ -187,18 +188,19 @@ export default function NotificationSettings() {
return (
<div className="animate-fade-in notification-settings-page" style={{ paddingBottom: 40 }}>
{/* 头部标题与操作 */}
<div className="page-header">
<h2 className="page-title">{tr('通知设置')}</h2>
<div className="page-actions">
<PageHeader
title={tr('通知设置')}
actions={(
<>
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
</button>
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
</button>
</div>
</div>
</>
)}
/>
<div className="notification-settings-layout" style={{ maxWidth: 980, display: 'flex', flexDirection: 'column', gap: 20 }}>
+31 -28
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import PageHeader from '../components/PageHeader.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
@@ -185,34 +186,36 @@ export default function ProgramLogs() {
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('程序日志')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
</button>
</div>
</div>
<PageHeader
title={tr('程序日志')}
actions={(
<>
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
</button>
</>
)}
/>
<ResponsiveFilterPanel
isMobile={isMobile}
+24 -25
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import PageHeader from '../components/PageHeader.js';
import { useToast } from '../components/Toast.js';
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
import { tr } from '../i18n.js';
@@ -176,31 +177,29 @@ export default function SiteAnnouncements() {
return (
<div className="animate-fade-in">
<div className="page-header">
<div>
<h2 className="page-title">{tr('用户公告')}</h2>
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
管理员发布给普通用户的站内公告,会在普通用户面板中展示。
</div>
</div>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={clearAll}
disabled={clearing || rows.length === 0}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
</button>
</div>
</div>
<PageHeader
title={tr('用户公告')}
subtitle="管理员发布给普通用户的站内公告,会在普通用户面板中展示。"
actions={(
<>
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={clearAll}
disabled={clearing || rows.length === 0}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
</button>
</>
)}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 14, alignItems: 'start' }}>
<div className="card" style={{ padding: 18 }}>
+15 -14
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { userApi } from '../api.js';
import PageHeader from '../components/PageHeader.js';
import { useToast } from '../components/Toast.js';
interface LogItem {
@@ -182,20 +183,20 @@ export default function UsageRecords() {
return (
<div className="page-container animate-fade-in">
<div className="page-header">
<div>
<h2 className="page-title">使用记录</h2>
<div className="page-subtitle">当前账号下所有 API 调用日志</div>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => load(page, search, statusFilter, dateFrom, dateTo)}
disabled={loading}
>
刷新
</button>
</div>
<PageHeader
title="使用记录"
subtitle="当前账号下所有 API 调用日志"
actions={(
<button
type="button"
className="btn btn-ghost"
onClick={() => load(page, search, statusFilter, dateFrom, dateTo)}
disabled={loading}
>
刷新
</button>
)}
/>
{/* Summary cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
+26 -23
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { userApi } from '../api.js';
import PageHeader from '../components/PageHeader.js';
import { useToast } from '../components/Toast.js';
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
@@ -76,29 +77,31 @@ export default function UserAnnouncements() {
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">用户公告</h2>
<div className="page-actions">
<button
type="button"
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
type="button"
onClick={markAllRead}
disabled={markingAll || rows.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
</div>
</div>
<PageHeader
title="用户公告"
actions={(
<>
<button
type="button"
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
type="button"
onClick={markAllRead}
disabled={markingAll || rows.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
</>
)}
/>
<div className="card" style={{ padding: 0 }}>
{loading ? (
+15 -14
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import PageHeader from '../components/PageHeader.js';
type UserRole = 'admin' | 'user';
type UserStatus = 'active' | 'disabled';
@@ -493,20 +494,20 @@ export default function UserManagement() {
return (
<div className="page-container animate-fade-in user-management-page">
<div className="page-header">
<div>
<h2 className="page-title">用户管理</h2>
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
</div>
<div className="page-actions">
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
刷新
</button>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
新建用户
</button>
</div>
</div>
<PageHeader
title="用户管理"
subtitle="维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。"
actions={(
<>
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
刷新
</button>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
新建用户
</button>
</>
)}
/>
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}