fix: P1审计修复 - CSS变量替换、PageHead补充、antd组件替换 (14个文件) #138

Merged
xiaoxia merged 1 commits from fix/sprint1-3-p1-audit-fixes into develop 2026-07-01 10:56:48 +08:00
14 changed files with 621 additions and 417 deletions
+119 -119
View File
@@ -2,52 +2,52 @@
/* ==================== 按钮 ==================== */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
background: var(--gradient-primary) !important;
color: var(--text-inverse) !important;
border: none !important;
border-radius: 14px !important;
border-radius: var(--radius-md) !important;
padding: 10px 20px !important;
font-weight: 600 !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
transition: all 0.2s !important;
font-weight: var(--font-weight-bold) !important;
box-shadow: var(--shadow-primary) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-primary-btn:hover {
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: transparent !important;
color: #4f46e5 !important;
border: 2px solid #4f46e5 !important;
border-radius: 14px !important;
padding: 8px 18px !important;
font-weight: 600 !important;
transition: all 0.2s !important;
color: var(--primary-color) !important;
border: 2px solid var(--primary-color) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-sm) 18px !important;
font-weight: var(--font-weight-bold) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-ghost-btn:hover {
background: #eef2ff !important;
background: var(--primary-soft) !important;
}
/* ==================== 卡片 ==================== */
.xx-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 24px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
margin-bottom: 20px;
transition: all 0.25s;
transition: all var(--transition-slow);
}
.xx-card:hover {
box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
@@ -55,7 +55,7 @@
.xx-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
padding: var(--space-lg);
}
.xx-page-head {
@@ -68,63 +68,63 @@
.xx-page-head h2 {
font-size: 26px;
font-weight: 850;
color: #0f172a;
margin: 0 0 8px;
font-weight: var(--font-weight-extrabold);
color: var(--text-primary);
margin: 0 0 var(--space-sm);
}
.xx-page-head p {
font-size: 14px;
color: #64748b;
font-size: var(--font-size-base);
color: var(--text-secondary);
margin: 0;
}
/* ==================== 表格样式 ==================== */
.xx-table-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: 20px;
overflow: hidden;
}
/* 表格包装器 */
.xx-table-wrapper {
border-radius: 16px;
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ==================== 标签/Tag ==================== */
.xx-tag {
padding: 4px 12px;
border-radius: 10px;
padding: var(--space-xs) 12px;
border-radius: var(--radius-xs);
font-size: 13px;
font-weight: 500;
font-weight: var(--font-weight-medium);
}
.xx-tag-indigo {
background: #eef2ff;
color: #4f46e5;
border: 1px solid #c7d2fe;
background: var(--primary-soft);
color: var(--primary-color);
border: 1px solid var(--color-primary-200);
}
.xx-tag-success {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
background: var(--success-soft);
color: var(--color-secondary-500);
border: 1px solid var(--success-border);
}
.xx-tag-warning {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
background: var(--warning-soft);
color: var(--accent-dark);
border: 1px solid var(--color-accent-200);
}
.xx-tag-error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
background: var(--error-soft);
color: var(--error-color);
border: 1px solid var(--error-border);
}
/* ==================== 搜索栏 ==================== */
@@ -135,52 +135,52 @@
.xx-search-input {
width: 100%;
padding: 12px 18px;
border: 2px solid #e2e8f0;
border-radius: 14px;
font-size: 14px;
background: white;
transition: all 0.2s;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--bg-primary);
transition: var(--transition-all);
outline: none;
}
.xx-search-input:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== Modal ==================== */
.xx-modal .ant-modal-content {
border-radius: 22px;
padding: 24px;
border-radius: var(--radius-xl);
padding: var(--space-lg);
}
.xx-modal .ant-modal-header {
border-radius: 22px 22px 0 0;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: 20px var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.xx-modal .ant-modal-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-modal .ant-modal-footer {
border-top: 1px solid #e2e8f0;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
}
/* ==================== 空状态 ==================== */
.xx-empty-state {
text-align: center;
padding: 48px 24px;
color: #64748b;
padding: var(--space-3xl) var(--space-lg);
color: var(--text-secondary);
}
.xx-empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
margin-bottom: var(--space-md);
}
/* ==================== 网格布局 ==================== */
@@ -213,15 +213,15 @@
/* ==================== 配额展示 ==================== */
.xx-quota-item {
padding: 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 16px;
transition: all 0.2s;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
transition: var(--transition-all);
}
.xx-quota-item:hover {
border-color: #4f46e5;
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== 进度条 ==================== */
@@ -232,130 +232,130 @@
/* ==================== Ant Design 覆盖样式 ==================== */
/* Table overrides */
.ant-table-wrapper .ant-table-thead > tr > th {
background: #f8fafc !important;
font-weight: 700 !important;
color: #0f172a !important;
border-bottom: 2px solid #e2e8f0 !important;
padding: 14px 16px !important;
background: var(--bg-secondary) !important;
font-weight: var(--font-weight-bold) !important;
color: var(--text-primary) !important;
border-bottom: 2px solid var(--border-color) !important;
padding: 14px var(--space-md) !important;
}
.ant-table-wrapper .ant-table-tbody > tr > td {
padding: 14px 16px !important;
border-bottom: 1px solid #f1f5f9 !important;
padding: 14px var(--space-md) !important;
border-bottom: 1px solid var(--color-gray-100) !important;
}
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
background: #fafbfc !important;
background: var(--color-gray-50) !important;
}
/* Card overrides */
.ant-card {
border-radius: 22px !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
border-radius: var(--radius-xl) !important;
border: 1px solid var(--border-color) !important;
}
.ant-card-head {
border-bottom: 1px solid #e2e8f0 !important;
border-bottom: 1px solid var(--border-color) !important;
min-height: 52px !important;
padding: 0 24px !important;
padding: 0 var(--space-lg) !important;
}
.ant-card-head-title {
font-weight: 700 !important;
font-size: 16px !important;
color: #0f172a !important;
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-md) !important;
color: var(--text-primary) !important;
}
.ant-card-body {
padding: 20px 24px !important;
padding: 20px var(--space-lg) !important;
}
/* Modal overrides */
.ant-modal-content {
border-radius: 22px !important;
border-radius: var(--radius-xl) !important;
overflow: hidden;
}
.ant-modal-header {
padding: 20px 24px !important;
background: white !important;
padding: 20px var(--space-lg) !important;
background: var(--bg-primary) !important;
}
.ant-modal-title {
font-weight: 700 !important;
font-size: 18px !important;
color: #0f172a !important;
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-lg) !important;
color: var(--text-primary) !important;
}
.ant-modal-body {
padding: 24px !important;
padding: var(--space-lg) !important;
}
.ant-modal-footer {
padding: 16px 24px !important;
padding: var(--space-md) var(--space-lg) !important;
}
/* Button overrides */
.ant-btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
background: var(--gradient-primary) !important;
border: none !important;
border-radius: 14px !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-primary) !important;
height: auto !important;
padding: 10px 20px !important;
font-weight: 600 !important;
font-weight: var(--font-weight-bold) !important;
}
.ant-btn-primary:hover {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
background: var(--gradient-primary) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
/* Tag overrides */
.ant-tag {
border-radius: 10px !important;
padding: 4px 12px !important;
font-weight: 500 !important;
border-radius: var(--radius-xs) !important;
padding: var(--space-xs) 12px !important;
font-weight: var(--font-weight-medium) !important;
}
/* Select overrides */
.ant-select-selector {
border-radius: 14px !important;
border-color: #e2e8f0 !important;
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
}
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
border-color: #4f46e5 !important;
border-color: var(--primary-color) !important;
}
.ant-select-focused .ant-select-selector {
border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Input overrides */
.ant-input {
border-radius: 14px !important;
border-color: #e2e8f0 !important;
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
padding: 10px 14px !important;
}
.ant-input:hover {
border-color: #4f46e5 !important;
border-color: var(--primary-color) !important;
}
.ant-input:focus {
border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Progress overrides */
.ant-progress-inner {
background: #f1f5f9 !important;
border-radius: 10px !important;
background: var(--color-gray-100) !important;
border-radius: var(--radius-xs) !important;
}
.ant-progress-bg {
border-radius: 10px !important;
border-radius: var(--radius-xs) !important;
}
+40 -40
View File
@@ -3,17 +3,17 @@
height: 68px;
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.86);
z-index: var(--z-fixed);
background: var(--bg-glass);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
border-bottom: 1px solid var(--border-color);
}
.xx-top-nav-inner {
max-width: 1440px;
margin: 0 auto;
height: 100%;
padding: 0 24px;
padding: 0 var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
@@ -23,12 +23,12 @@
display: flex;
gap: 10px;
align-items: center;
font-size: 20px;
font-weight: 900;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-extrabold);
border: none;
background: none;
cursor: pointer;
color: #0f172a;
color: var(--text-primary);
padding: 0;
flex-shrink: 0;
}
@@ -36,41 +36,41 @@
.xx-logo {
width: 38px;
height: 38px;
border-radius: 14px;
background: linear-gradient(135deg, #818cf8, #4f46e5);
border-radius: var(--radius-md);
background: var(--gradient-primary);
display: grid;
place-items: center;
color: #fff;
box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24);
font-size: 20px;
color: var(--text-inverse);
box-shadow: var(--shadow-primary);
font-size: var(--font-size-xl);
}
/* 桌面端导航链接 */
.xx-nav-links {
display: flex;
gap: 16px;
color: #64748b;
font-weight: 750;
gap: var(--space-md);
color: var(--text-secondary);
font-weight: var(--font-weight-bold);
}
.xx-nav-links button {
border: none;
background: none;
cursor: pointer;
color: #64748b;
color: var(--text-secondary);
padding: 6px 4px;
font-size: 14px;
font-weight: 750;
transition: color 0.2s;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
transition: color var(--transition-fast);
white-space: nowrap;
}
.xx-nav-links button:hover {
color: #4f46e5;
color: var(--primary-color);
}
.xx-nav-links button.active {
color: #4f46e5;
color: var(--primary-color);
}
/* 右侧区域 */
@@ -84,17 +84,17 @@
.xx-user-menu {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-sm);
cursor: pointer;
color: #0f172a;
font-size: 14px;
color: var(--text-primary);
font-size: var(--font-size-base);
}
.xx-avatar {
width: 34px;
height: 34px;
background: #eef2ff;
color: #4338ca;
background: var(--primary-soft);
color: var(--primary-dark);
}
/* 汉堡菜单按钮(默认隐藏) */
@@ -103,42 +103,42 @@
border: none;
background: none;
cursor: pointer;
font-size: 20px;
color: #64748b;
padding: 8px;
font-size: var(--font-size-xl);
color: var(--text-secondary);
padding: var(--space-sm);
}
/* 手机端导航抽屉内容 */
.xx-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-xs);
}
.xx-mobile-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding: 12px var(--space-md);
border: none;
background: none;
cursor: pointer;
font-size: 16px;
color: #334155;
border-radius: 8px;
transition: background 0.2s;
font-size: var(--font-size-md);
color: var(--color-gray-700);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
width: 100%;
text-align: left;
}
.xx-mobile-nav-item:hover {
background: #f1f5f9;
background: var(--bg-tertiary);
}
.xx-mobile-nav-item.active {
background: #eef2ff;
color: #4f46e5;
font-weight: 600;
background: var(--primary-soft);
color: var(--primary-color);
font-weight: var(--font-weight-semibold);
}
.xx-mobile-nav-icon {
@@ -182,7 +182,7 @@
/* 小屏幕平板:导航文字缩小 */
@media (min-width: 769px) and (max-width: 1024px) {
.xx-nav-links {
gap: 8px;
gap: var(--space-sm);
}
.xx-nav-links button {
@@ -24,6 +24,7 @@ import {
} from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
import PageHead from "@/components/layout/PageHead";
/* ============================================================
* 类型
@@ -13,6 +13,7 @@ import {
type DuplicateSegment,
} from "@/api/duplication";
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
/** 格式化时间(秒 → mm:ss */
const formatTime = (seconds: number) => {
@@ -191,51 +192,41 @@ const DuplicationDetail: React.FC = () => {
</div>
)}
{/* 返回按钮 */}
<button
className="dup-detail-back"
onClick={() => navigate("/duplication/results")}
>
</button>
{/* 页头 */}
<div className="dup-detail-header">
<div>
<h2>🎬 {detail.filename}</h2>
<div className="dup-detail-header-meta">
<PageHead
title={`🎬 ${detail.filename}`}
description={
<div className="dup-detail-header-meta" style={{ display: "flex", gap: 12, alignItems: "center", marginTop: 8 }}>
<Tag variant={RISK_TAG_VARIANT[riskLevel]}>
{RISK_LABEL[riskLevel]}
</Tag>
<span>{formatSize(detail.file_size)}</span>
<span>{formatDuration(detail.duration_seconds)}</span>
<span>
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
<span> {new Date(detail.created_at).toLocaleString("zh-CN")}</span>
</div>
</div>
<div className="dup-detail-actions">
<Button
buttonType="secondary"
buttonSize="md"
onClick={() => {
/* TODO: 下载报告 */
showToast("报告下载功能开发中", "warning");
}}
>
📥
</Button>
{detail.status === "failed" && (
}
actions={
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
<Button
buttonType="primary"
buttonType="secondary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
onClick={() => {
showToast("报告下载功能开发中", "warning");
}}
>
🔄
📥
</Button>
)}
</div>
</div>
{detail.status === "failed" && (
<Button
buttonType="primary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
>
🔄
</Button>
)}
</div>
}
/>
{/* 内容网格 */}
<div className="dup-detail-grid">
@@ -13,6 +13,7 @@ import {
type DuplicationStatus,
} from "@/api/duplication";
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
/** 风险等级分类 */
type RiskFilter = "all" | "high" | "medium" | "low";
@@ -131,10 +132,10 @@ const DuplicationResults: React.FC = () => {
</div>
)}
{/* 页头 */}
<div className="dup-results-header">
<h2>📋 </h2>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<PageHead
title="查重记录"
actions={
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{/* 筛选胶囊 */}
<div className="dup-filter">
{FILTER_OPTIONS.map((opt) => (
@@ -154,8 +155,9 @@ const DuplicationResults: React.FC = () => {
>
📤
</Button>
</div>
</div>
</div>
}
/>
{/* 加载中 */}
{isLoading && (
@@ -9,6 +9,7 @@ import { Button, Card, Tag } from "@/components/ui";
import { uploadForDuplication } from "@/api/duplication";
import { useNavigate } from "react-router-dom";
import "./duplication.css";
import PageHead from "@/components/layout/PageHead";
/** 支持的视频格式 */
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm";
@@ -123,12 +124,7 @@ const DuplicationUpload: React.FC = () => {
</div>
)}
<div className="dup-page-header">
<h2>📹 </h2>
<p>
</p>
</div>
<PageHead title="视频查重" description="上传视频文件,系统将自动检测与已有素材的重复片段,帮助您避免重复内容。" />
<div className="dup-upload-grid">
{/* 左侧:上传区域 */}
+57 -35
View File
@@ -1,61 +1,83 @@
/**
* 个人设置页面
* P1-2: 添加 PageHead
* P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件
*/
import React from "react";
import { Form, Input, Button, Alert } from "antd";
import React, { useState } from "react";
import { Button, Input, Modal } from "@/components/ui";
import { useAuthStore } from "@/store/authStore";
import PageHead from "@/components/layout/PageHead";
import "./ProfileSettings.css";
const Settings: React.FC = () => {
const user = useAuthStore((state) => state.user);
const [form] = Form.useForm();
const [displayName, setDisplayName] = useState(user?.display_name || "");
const [saving, setSaving] = useState(false);
const onFinish = () => undefined;
const handleSave = () => {
Modal.info({
title: "提示",
content: "个人资料修改接口暂未开放,保存功能即将上线。",
});
};
return (
<div className="xx-settings-page">
<PageHead title="个人设置" description="管理您的账户信息" />
<div className="xx-settings-card">
<h3></h3>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="个人资料编辑暂未开放"
description="当前仅展示登录用户信息,资料修改接口接入后再开放保存。"
/>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{
username: user?.username,
email: user?.email,
display_name: user?.display_name,
}}
>
<Form.Item label="用户名" name="username">
<Input disabled />
</Form.Item>
<div className="xx-settings-notice">
<span className="xx-settings-notice-icon"></span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<Form.Item label="邮箱" name="email">
<Input disabled />
</Form.Item>
<div className="xx-settings-form">
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={user?.username || ""}
disabled
placeholder="用户名"
/>
</div>
<Form.Item label="显示名称" name="display_name">
<Input />
</Form.Item>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={user?.email || ""}
disabled
placeholder="邮箱"
/>
</div>
<Form.Item>
<Button type="primary" htmlType="submit" disabled>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入显示名称"
/>
</div>
<div className="xx-settings-field">
<Button
buttonType="primary"
buttonSize="md"
onClick={handleSave}
disabled
>
</Button>
</Form.Item>
</Form>
</div>
</div>
</div>
</div>
);
};
export default Settings;
export const Component = Settings;
+147 -77
View File
@@ -1,128 +1,198 @@
/* 账单管理页面 */
/**
* 账单管理页面样式
* P1-3: 全部使用 CSS 变量 + 新增 ToggleSwitch/Spinner 样式
*/
.xx-billing-page {
max-width: 960px;
max-width: 800px;
margin: 0 auto;
padding: 40px 24px;
}
/* 订阅概览 */
.xx-billing-overview {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 20px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 32px;
margin-bottom: 40px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.xx-billing-overview h2 {
font-size: 24px;
font-weight: 800;
color: var(--slate, #0f172a);
margin: 0 0 24px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 20px;
}
.xx-overview-details {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 768px) {
@media (max-width: 640px) {
.xx-overview-details {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
}
}
.xx-overview-item {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.xx-label {
font-size: 13px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-weight: 500;
}
.xx-value {
font-size: 16px;
font-weight: 700;
color: var(--slate, #0f172a);
color: var(--text-primary);
font-weight: 600;
}
/* 账单记录 */
.xx-billing-history {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 20px;
/* 自动续费 */
.xx-billing-auto-renew {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 32px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
box-shadow: var(--shadow-sm);
}
.xx-billing-history h2 {
font-size: 24px;
font-weight: 800;
color: var(--slate, #0f172a);
margin: 0 0 24px;
}
/* 表格样式 */
.xx-billing-table {
width: 100%;
}
.xx-table-header {
display: grid;
grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr;
gap: 12px;
padding: 12px 16px;
background: rgba(241, 245, 249, 0.8);
border-radius: 10px;
margin-bottom: 8px;
font-size: 13px;
.xx-billing-auto-renew h2 {
font-size: 20px;
font-weight: 700;
color: var(--muted, #64748b);
color: var(--text-primary);
margin: 0 0 20px;
}
.xx-table-row {
display: grid;
grid-template-columns: 1.2fr 1fr 0.8fr 1fr 0.8fr 1fr;
gap: 12px;
padding: 14px 16px;
.xx-auto-renew-row {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
font-size: 14px;
color: var(--slate, #0f172a);
transition: background 0.15s;
justify-content: space-between;
gap: 24px;
}
.xx-table-row:hover {
background: rgba(241, 245, 249, 0.4);
.xx-auto-renew-info {
flex: 1;
}
.xx-table-row:last-child {
border-bottom: none;
.xx-auto-renew-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.xx-amount {
font-weight: 700;
color: var(--indigo, #4f46e5);
.xx-auto-renew-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
@media (max-width: 768px) {
.xx-table-header,
.xx-table-row {
grid-template-columns: 1fr 1fr 1fr;
font-size: 12px;
}
.xx-table-header span:nth-child(4),
.xx-table-header span:nth-child(6),
.xx-table-row span:nth-child(4),
.xx-table-row span:nth-child(6) {
display: none;
}
/* ── ToggleSwitch 组件 ───────────────────────────────── */
.xx-toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
width: 44px;
height: 24px;
padding: 0;
border: none;
border-radius: 12px;
background: var(--color-gray-300);
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.xx-toggle-switch.checked {
background: var(--primary-color);
}
.xx-toggle-switch.loading {
opacity: 0.65;
cursor: not-allowed;
}
.xx-toggle-switch-handle {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-primary);
box-shadow: var(--shadow-xs);
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.xx-toggle-switch.checked .xx-toggle-switch-handle {
transform: translateX(20px);
}
.xx-toggle-switch-inner {
position: absolute;
width: 100%;
text-align: center;
font-size: 0;
/* 文字不显示,仅保留 aria 语义 */
}
.xx-toggle-switch-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--primary-color);
border-top-color: transparent;
border-radius: 50%;
animation: xx-toggle-spin 0.6s linear infinite;
}
@keyframes xx-toggle-spin {
to { transform: rotate(360deg); }
}
/* ── Spinner 组件 ───────────────────────────────── */
.xx-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
}
.xx-spinner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color);
animation: xx-spinner-bounce 1.2s ease-in-out infinite;
}
.xx-spinner-dot:nth-child(2) {
animation-delay: 0.15s;
}
.xx-spinner-dot:nth-child(3) {
animation-delay: 0.3s;
}
.xx-spinner--small .xx-spinner-dot {
width: 6px;
height: 6px;
}
@keyframes xx-spinner-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
+41 -3
View File
@@ -1,11 +1,13 @@
/**
* 账单管理页面
* 展示当前订阅信息 + 自动续费开关
* P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner
*/
import React, { useState, useEffect } from "react";
import { Switch, message, Spin } from "antd";
import { message } from "antd";
import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription";
import type { SubscriptionInfo } from "@/api/subscription";
import PageHead from "@/components/layout/PageHead";
import "./Billing.css";
const formatDate = (iso: string): string => {
@@ -17,6 +19,40 @@ const formatDate = (iso: string): string => {
});
};
/** 自定义 ToggleSwitch 组件 */
const ToggleSwitch: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
loading?: boolean;
checkedChildren?: string;
unCheckedChildren?: string;
}> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => (
<button
type="button"
className={`xx-toggle-switch ${checked ? "checked" : ""} ${loading ? "loading" : ""}`}
onClick={() => !loading && onChange(!checked)}
disabled={loading}
role="switch"
aria-checked={checked}
>
<span className="xx-toggle-switch-handle">
{loading && <span className="xx-toggle-switch-spinner" />}
</span>
<span className="xx-toggle-switch-inner">
{checked ? checkedChildren : unCheckedChildren}
</span>
</button>
);
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
);
const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
@@ -62,13 +98,15 @@ const Billing: React.FC = () => {
if (loading) {
return (
<div className="xx-billing-page">
<Spin size="large" />
<Spinner size="large" />
</div>
);
}
return (
<div className="xx-billing-page">
<PageHead title="账单管理" description="管理您的订阅和账单信息" />
{subscription && (
<>
{/* 当前订阅概览 */}
@@ -104,7 +142,7 @@ const Billing: React.FC = () => {
</p>
</div>
<Switch
<ToggleSwitch
checked={autoRenewChecked}
onChange={handleToggleAutoRenew}
loading={autoRenewLoading}
+37 -35
View File
@@ -1,4 +1,4 @@
/* V21 定价页面 */
/* V21 定价页面 - P1-2/1-5: CSS 变量替换 */
.xx-plans-page {
max-width: 1200px;
margin: 0 auto;
@@ -14,14 +14,14 @@
.xx-page-head h2 {
font-size: 42px;
font-weight: 900;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 16px;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 18px;
color: var(--muted, #64748b);
color: var(--text-secondary);
margin: 0;
max-width: 560px;
margin-left: auto;
@@ -53,10 +53,10 @@
/* V21 定价卡片 */
.xx-plan-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 28px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
box-shadow: var(--shadow-card);
padding: 32px 28px;
position: relative;
transition: all 0.3s;
@@ -65,16 +65,16 @@
}
.xx-plan-card:hover {
border-color: var(--indigo, #4f46e5);
box-shadow: 0 32px 90px rgba(79, 70, 229, 0.15);
border-color: var(--primary-color);
box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
transform: translateY(-4px);
}
.xx-plan-card.featured {
border: 2px solid var(--indigo, #4f46e5);
border: 2px solid var(--primary-color);
box-shadow:
0 0 0 4px rgba(79, 70, 229, 0.1),
0 32px 90px rgba(79, 70, 229, 0.15);
0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent),
0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
transform: scale(1.02);
}
@@ -88,8 +88,8 @@
top: -12px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
background: linear-gradient(135deg, var(--color-primary-500), var(--primary-color));
color: var(--text-inverse);
padding: 6px 16px;
border-radius: 999px;
font-size: 12px;
@@ -99,14 +99,14 @@
/* 企业版定制标签 */
.xx-badge.enterprise {
background: linear-gradient(135deg, #f59e0b, #d97706);
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
}
/* 套餐名称 */
.xx-plan-card h3 {
font-size: 22px;
font-weight: 900;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 20px;
text-align: center;
}
@@ -120,20 +120,20 @@
.xx-plan-price .currency {
font-size: 18px;
font-weight: 700;
color: var(--muted, #64748b);
color: var(--text-secondary);
vertical-align: top;
}
.xx-plan-price .amount {
font-size: 48px;
font-weight: 900;
color: var(--indigo, #4f46e5);
color: var(--primary-color);
line-height: 1;
}
.xx-plan-price .period {
font-size: 16px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-weight: 500;
}
@@ -144,12 +144,12 @@
/* 描述 */
.xx-plan-description {
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
text-align: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--line, #e2e8f0);
border-bottom: 1px solid var(--border-color);
}
/* 功能列表 */
@@ -163,14 +163,14 @@
display: flex;
gap: 12px;
align-items: flex-start;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.xx-feature span:first-child {
color: var(--green, #10b981);
color: var(--secondary-color);
font-weight: 900;
flex-shrink: 0;
}
@@ -182,37 +182,39 @@
padding: 14px 24px !important;
font-size: 15px !important;
font-weight: 850 !important;
border-radius: 14px !important;
border-radius: var(--radius-md) !important;
transition: all 0.2s !important;
cursor: pointer;
border: none;
}
.xx-subscribe-btn.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
background: linear-gradient(135deg, var(--color-primary-500), var(--primary-color)) !important;
color: var(--text-inverse) !important;
border: none !important;
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
box-shadow: var(--shadow-primary) !important;
}
.xx-subscribe-btn.primary:hover {
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-subscribe-btn.ghost {
background: white !important;
border: 1px solid var(--line, #e2e8f0) !important;
color: var(--slate, #0f172a) !important;
background: var(--bg-primary) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
}
.xx-subscribe-btn.ghost:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
/* 企业版按钮 */
.xx-subscribe-btn.enterprise {
background: linear-gradient(135deg, #f59e0b, #d97706) !important;
color: white !important;
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important;
color: var(--text-inverse) !important;
border: none !important;
box-shadow: 0 14px 26px rgba(245, 158, 11, 0.22) !important;
box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
}
@@ -1,28 +1,10 @@
/* 升级/降级页面 */
/* 升级/降级页面 - P1-3: 全部使用 CSS 变量 */
.xx-upgrade-page {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px;
}
.xx-upgrade-header {
text-align: center;
margin-bottom: 40px;
}
.xx-upgrade-header h2 {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
}
.xx-upgrade-header p {
font-size: 16px;
color: var(--muted, #64748b);
margin: 0;
}
/* 套餐卡片 */
.xx-upgrade-plans {
display: grid;
@@ -38,8 +20,8 @@
}
.xx-upgrade-card {
background: rgba(255, 255, 255, 0.94);
border: 2px solid rgba(226, 232, 240, 0.95);
background: var(--bg-elevated);
border: 2px solid var(--border-color);
border-radius: 20px;
padding: 28px 24px;
text-align: center;
@@ -49,27 +31,27 @@
}
.xx-upgrade-card:hover {
border-color: var(--indigo, #4f46e5);
box-shadow: 0 8px 30px rgba(79, 70, 229, 0.1);
border-color: var(--primary-color);
box-shadow: 0 8px 30px color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.xx-upgrade-card.selected {
border-color: var(--indigo, #4f46e5);
border-color: var(--primary-color);
box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.12),
0 8px 30px rgba(79, 70, 229, 0.1);
0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent),
0 8px 30px color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.xx-upgrade-card.current {
border-color: var(--green, #10b981);
border-color: var(--secondary-color);
}
.xx-current-badge {
position: absolute;
top: -10px;
right: 16px;
background: var(--green, #10b981);
color: white;
background: var(--secondary-color);
color: var(--text-inverse);
padding: 3px 12px;
border-radius: 999px;
font-size: 12px;
@@ -79,7 +61,7 @@
.xx-upgrade-card h3 {
font-size: 20px;
font-weight: 800;
color: var(--slate, #0f172a);
color: var(--text-primary);
margin: 0 0 16px;
}
@@ -87,14 +69,48 @@
margin-bottom: 8px;
}
/* ── BillingCycleSwitch 组件 ──────────────────────── */
.xx-billing-cycle-switch {
display: inline-flex;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
}
.xx-billing-cycle-btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border: none;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.xx-billing-cycle-btn:first-child {
border-right: 1px solid var(--border-color);
}
.xx-billing-cycle-btn.active {
background: var(--primary-color);
color: var(--text-inverse);
}
.xx-save {
display: inline-block;
margin-left: 4px;
font-size: 11px;
color: var(--green, #10b981);
color: var(--secondary-color);
font-weight: 600;
}
.xx-billing-cycle-btn.active .xx-save {
color: var(--text-inverse);
opacity: 0.85;
}
/* 操作区 */
.xx-upgrade-actions {
text-align: center;
@@ -108,10 +124,45 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--muted, #64748b);
color: var(--text-secondary);
font-size: 14px;
}
.xx-cancel-btn {
margin-top: 8px;
}
/* ── Spinner 组件 ───────────────────────────────── */
.xx-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
}
.xx-spinner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-color);
animation: xx-spinner-bounce 1.2s ease-in-out infinite;
}
.xx-spinner-dot:nth-child(2) {
animation-delay: 0.15s;
}
.xx-spinner-dot:nth-child(3) {
animation-delay: 0.3s;
}
.xx-spinner--small .xx-spinner-dot {
width: 6px;
height: 6px;
}
@keyframes xx-spinner-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
@@ -1,8 +1,10 @@
/**
* 升级/降级/续费页面
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
*/
import React, { useState, useEffect } from "react";
import { Button, Radio, message, Spin, Modal } from "antd";
import { message } from "antd";
import { Button, Modal } from "@/components/ui";
import { useNavigate } from "react-router-dom";
import {
getCurrentSubscription,
@@ -15,6 +17,7 @@ import type {
PlanType,
BillingCycle,
} from "@/api/subscription";
import PageHead from "@/components/layout/PageHead";
import "./UpgradeSubscription.css";
const PLANS_META: Record<
@@ -27,6 +30,45 @@ const PLANS_META: Record<
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
};
/** 自定义计费周期切换组件 */
const BillingCycleSwitch: React.FC<{
value: BillingCycle;
onChange: (cycle: BillingCycle) => void;
monthlyPrice: number;
yearlyPrice: number;
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
<div className="xx-billing-cycle-switch">
<button
type="button"
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
onClick={() => onChange("monthly")}
>
¥{monthlyPrice}/
</button>
<button
type="button"
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
onClick={() => onChange("yearly")}
>
¥{yearlyPrice}/
{yearlyPrice > 0 && monthlyPrice > 0 && (
<span className="xx-save">
¥{monthlyPrice * 12 - yearlyPrice}
</span>
)}
</button>
</div>
);
/** 自定义 Spinner 组件 */
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
<div className={`xx-spinner xx-spinner--${size}`}>
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
<div className="xx-spinner-dot" />
</div>
);
const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate();
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
@@ -113,7 +155,6 @@ const UpgradeSubscription: React.FC = () => {
title: "确认取消订阅",
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: "确认取消",
okType: "danger",
cancelText: "再想想",
onOk: async () => {
try {
@@ -131,7 +172,7 @@ const UpgradeSubscription: React.FC = () => {
if (loading) {
return (
<div className="xx-upgrade-page">
<Spin size="large" />
<Spinner size="large" />
</div>
);
}
@@ -140,10 +181,7 @@ const UpgradeSubscription: React.FC = () => {
return (
<div className="xx-upgrade-page">
<div className="xx-upgrade-header">
<h2></h2>
<p>{PLANS_META[currentPlan]?.name ?? "体验版"}</p>
</div>
<PageHead title="变更订阅方案" description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`} />
<div className="xx-upgrade-plans">
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
@@ -158,21 +196,12 @@ const UpgradeSubscription: React.FC = () => {
{isCurrent && <div className="xx-current-badge"></div>}
<h3>{plan.name}</h3>
<div className="xx-price">
<Radio.Group
<BillingCycleSwitch
value={billingCycle}
onChange={(e) => setBillingCycle(e.target.value)}
size="small"
>
<Radio.Button value="monthly">¥{plan.price}/</Radio.Button>
<Radio.Button value="yearly">
¥{plan.yearlyPrice}/
{plan.yearlyPrice > 0 && plan.price > 0 && (
<span className="xx-save">
¥{plan.price * 12 - plan.yearlyPrice}
</span>
)}
</Radio.Button>
</Radio.Group>
onChange={setBillingCycle}
monthlyPrice={plan.price}
yearlyPrice={plan.yearlyPrice}
/>
</div>
</div>
);
@@ -181,13 +210,12 @@ const UpgradeSubscription: React.FC = () => {
<div className="xx-upgrade-actions">
<Button
type="primary"
size="large"
loading={submitting}
buttonType="primary"
buttonSize="lg"
disabled={submitting || selectedPlan === currentPlan}
onClick={handleUpgrade}
disabled={selectedPlan === currentPlan}
>
{submitting ? "处理中..." : "确认变更"}
</Button>
{subscription && subscription.status === "active" && (
@@ -196,7 +224,8 @@ const UpgradeSubscription: React.FC = () => {
{subscription.auto_renew ? "已开启" : "已关闭"}
</span>
<Button
type="link"
buttonType="text"
buttonSize="sm"
onClick={() => handleToggleAutoRenew(!subscription.auto_renew)}
>
{subscription.auto_renew ? "关闭" : "开启"}
@@ -208,8 +237,8 @@ const UpgradeSubscription: React.FC = () => {
subscription.status === "active" &&
currentPlan !== "free" && (
<Button
type="link"
danger
buttonType="danger"
buttonSize="sm"
onClick={handleCancel}
className="xx-cancel-btn"
>
@@ -24,6 +24,7 @@ import {
} from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui";
import "./titles.css";
import PageHead from "@/components/layout/PageHead";
/* ============================================================
* 类型
@@ -21,6 +21,7 @@ import {
} from "@ant-design/icons";
import { Button, Input, Select } from "@/components/ui";
import "./voices.css";
import PageHead from "@/components/layout/PageHead";
/* ============================================================
* 类型