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