初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
This commit is contained in:
commit
78ff261b5a
199 files changed
+69126
No files matched your search
@@ -0,0 +1,666 @@
|
||||
/* ===== Assets Page ===== */
|
||||
.assets-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.assets-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.assets-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.assets-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.assets-create-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.assets-create-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.assets-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assets-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.assets-tab:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.assets-tab.active {
|
||||
background: var(--tab-bg, var(--color-accent-light));
|
||||
color: var(--tab-color, var(--color-accent));
|
||||
border-color: var(--tab-color, var(--color-accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Sub-tabs */
|
||||
.assets-subtabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.assets-subtab {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.assets-subtab:hover { color: var(--color-text-primary); }
|
||||
.assets-subtab.active {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Assets List */
|
||||
.assets-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.assets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.novel-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* File Card */
|
||||
.asset-card {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.asset-card-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-file-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.asset-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.asset-card-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-card-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.asset-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.asset-card-size, .asset-card-date {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
/* Novel Card */
|
||||
.asset-novel-card {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-2xl);
|
||||
background: linear-gradient(135deg, #FFFBEB 0%, #FFF7ED 50%, #FFFBF0 100%);
|
||||
border: 1px solid #FDE68A;
|
||||
border-radius: var(--radius-xl);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.asset-novel-card:hover {
|
||||
border-color: #F59E0B;
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.novel-card-cover {
|
||||
width: 80px;
|
||||
height: 110px;
|
||||
background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
|
||||
border: 1px solid #FED7AA;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.novel-card-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #D97706;
|
||||
background: #FEF3C7;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.novel-card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.novel-card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.novel-card-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: #A16207;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.novel-card-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: #B45309;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.assets-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5xl);
|
||||
color: var(--color-text-disabled);
|
||||
font-size: var(--text-sm);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.assets-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.audit-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border-light);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Assets Modal ===== */
|
||||
.assets-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fadeIn var(--transition-fast);
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.assets-modal {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-2xl);
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
animation: slideUp var(--transition-normal);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.assets-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xl) var(--space-2xl);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.assets-modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.assets-modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.assets-modal-close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.assets-modal-body {
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.assets-modal-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.assets-modal-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.assets-modal-cat {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.assets-modal-size, .assets-modal-date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Text Preview */
|
||||
.assets-preview-text {
|
||||
margin-bottom: var(--space-xl);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.assets-preview-code {
|
||||
padding: var(--space-lg);
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Image Preview */
|
||||
.assets-preview-image {
|
||||
margin-bottom: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assets-preview-image img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Video Preview */
|
||||
.assets-preview-video {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.assets-preview-video video {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Audio Preview */
|
||||
.assets-preview-audio {
|
||||
margin-bottom: var(--space-xl);
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assets-preview-audio audio {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Edit Section */
|
||||
.assets-edit-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.assets-edit-textarea {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.assets-edit-textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.assets-edit-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Modal Actions */
|
||||
.assets-modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* Assets Buttons */
|
||||
.assets-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.assets-btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.assets-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.assets-btn-secondary {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.assets-btn-secondary:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.assets-btn-cancel {
|
||||
background: transparent;
|
||||
color: var(--color-text-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.assets-btn-cancel:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.assets-btn-danger {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.assets-btn-danger:hover {
|
||||
background: var(--color-error-light);
|
||||
}
|
||||
|
||||
/* Create Asset Modal */
|
||||
.assets-create-modal {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.assets-create-modal .form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-label .required {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-submit-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-submit-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.form-submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Online Preview */
|
||||
.assets-preview-online {
|
||||
margin: var(--space-md) 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assets-preview-online-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.assets-preview-reader {
|
||||
background: var(--color-bg-secondary);
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
.assets-kb-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
background: #EEEDFF;
|
||||
color: #6C63FF;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.assets-page { padding: var(--space-lg); }
|
||||
.novel-grid { grid-template-columns: 1fr; }
|
||||
.assets-grid { grid-template-columns: 1fr; }
|
||||
.asset-novel-card { flex-direction: column; }
|
||||
.novel-card-cover { width: 100%; height: 60px; flex-direction: row; }
|
||||
.assets-modal { max-width: 100%; max-height: 90vh; border-radius: var(--radius-xl); }
|
||||
.assets-modal-body { padding: var(--space-xl); }
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Asset, AssetCategory, AssetCategoryDef, AssetFileType } from '@/types';
|
||||
import { assetsAPI } from '@/api';
|
||||
import './AssetsPage.css';
|
||||
|
||||
const fileTypeLabels: Record<string, { label: string; bg: string; color: string }> = {
|
||||
word: { label: 'Word', bg: '#E8F1FF', color: '#2563EB' },
|
||||
excel: { label: 'Excel', bg: '#E8F8EE', color: '#059669' },
|
||||
ppt: { label: 'PPT', bg: '#FEF2E2', color: '#D97706' },
|
||||
pdf: { label: 'PDF', bg: '#FDEBEB', color: '#DC2626' },
|
||||
txt: { label: 'TXT', bg: '#F1F3F7', color: '#64748B' },
|
||||
md: { label: 'MD', bg: '#E8EEF8', color: '#3B82F6' },
|
||||
doc: { label: 'DOC', bg: '#E6F4EA', color: '#0D9488' },
|
||||
other: { label: '其他', bg: '#F1F3F7', color: '#64748B' },
|
||||
};
|
||||
|
||||
// SVG Icons
|
||||
const CloseIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const getFileTypeInfo = (ft?: string) => {
|
||||
return fileTypeLabels[ft || 'other'] || fileTypeLabels.other;
|
||||
};
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '--';
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// 将文件扩展名或API返回的file_type映射到前端fileType标签
|
||||
function mapFileTypeToLabel(ft: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
|
||||
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
|
||||
};
|
||||
return mapping[ft] || 'other';
|
||||
}
|
||||
|
||||
// Preview Modal Component
|
||||
interface PreviewModalProps {
|
||||
asset: Asset;
|
||||
onClose: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const PreviewModal: React.FC<PreviewModalProps> = ({ asset, onClose, onDelete }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [showOnlinePreview, setShowOnlinePreview] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isTextFile = asset.category === 'file' && ['txt', 'other', 'md', 'doc'].includes(asset.fileType || '');
|
||||
const isImage = asset.category === 'image';
|
||||
const isVideo = asset.category === 'video';
|
||||
const isAudio = asset.category === 'audio';
|
||||
const isTextEditable = asset.category === 'file' && (asset.fileType === 'txt' || asset.fileType === 'other' || asset.fileType === 'md' || asset.fileType === 'doc');
|
||||
const isOfficeDoc = asset.category === 'file' && ['word', 'excel', 'ppt'].includes(asset.fileType || '');
|
||||
const isPdf = asset.category === 'file' && asset.fileType === 'pdf';
|
||||
|
||||
const handleDownload = () => {
|
||||
// Simulate download - in production would use asset.url
|
||||
const a = document.createElement('a');
|
||||
a.href = '#' + asset.id;
|
||||
a.download = asset.name;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditContent(asset.description || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
// In production, would call assetsAPI.updateAsset
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
// 在线预览内容渲染
|
||||
const renderOnlinePreview = () => {
|
||||
if (!showOnlinePreview) return null;
|
||||
const ft = asset.fileType || 'other';
|
||||
|
||||
// PDF 使用浏览器原生预览
|
||||
if (ft === 'pdf' && asset.url) {
|
||||
return (
|
||||
<div className="assets-preview-online">
|
||||
<div className="assets-preview-online-toolbar">
|
||||
<span>在线预览:{asset.name}</span>
|
||||
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
||||
关闭预览
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
src={asset.url}
|
||||
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
|
||||
title={`预览 ${asset.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Office文档(docx/xlsx/pptx) 使用微软Office Online查看器
|
||||
if (asset.url && ['word', 'excel', 'ppt'].includes(ft)) {
|
||||
const encodedUrl = encodeURIComponent(asset.url);
|
||||
const officeViewerUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodedUrl}`;
|
||||
return (
|
||||
<div className="assets-preview-online">
|
||||
<div className="assets-preview-online-toolbar">
|
||||
<span>在线预览:{asset.name}</span>
|
||||
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
||||
关闭预览
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
src={officeViewerUrl}
|
||||
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
|
||||
title={`预览 ${asset.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 文本类文件的在线预览
|
||||
if (['txt', 'word', 'other', 'md', 'doc'].includes(ft)) {
|
||||
const previewContent = (asset as any).content || asset.description || '(文件内容预览)\n\n该文件暂无内容预览。\n\n--- 在线预览模式 ---\n您正在查看文件的只读预览。';
|
||||
return (
|
||||
<div className="assets-preview-online">
|
||||
<div className="assets-preview-online-toolbar">
|
||||
<span>在线预览:{asset.name}</span>
|
||||
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
||||
关闭预览
|
||||
</button>
|
||||
</div>
|
||||
<div className="assets-preview-reader">
|
||||
<pre style={{whiteSpace:'pre-wrap',fontFamily:'var(--font-mono)',fontSize:'var(--text-sm)',lineHeight:1.8,padding:'var(--space-xl)',color:'var(--color-text-primary)'}}>
|
||||
{previewContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assets-modal-overlay" onClick={onClose}>
|
||||
<div className="assets-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="assets-modal-header">
|
||||
<h3 className="assets-modal-title">{asset.name}</h3>
|
||||
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
|
||||
</div>
|
||||
<div className="assets-modal-body">
|
||||
<div className="assets-modal-meta">
|
||||
<span className="assets-modal-type">
|
||||
{asset.category === 'file' && (
|
||||
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
|
||||
{getFileTypeInfo(asset.fileType).label}
|
||||
</span>
|
||||
)}
|
||||
<span className="assets-modal-cat">{asset.category === 'image' ? '图片' : asset.category === 'video' ? '视频' : asset.category === 'audio' ? '音频' : asset.category === 'novel' ? '小说' : '文件'}</span>
|
||||
</span>
|
||||
{asset.size && <span className="assets-modal-size">{formatSize(asset.size)}</span>}
|
||||
<span className="assets-modal-date">{formatDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Text preview */}
|
||||
{isTextFile && !editing && !showOnlinePreview && (
|
||||
<div className="assets-preview-text">
|
||||
<pre className="assets-preview-code">
|
||||
{(asset as any).content || asset.description || '(文件内容预览)\n\n这是一个文本类型的文件。\n内容由后端API提供。'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text editing */}
|
||||
{isTextEditable && editing && (
|
||||
<div className="assets-edit-section">
|
||||
<textarea
|
||||
className="assets-edit-textarea"
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
rows={12}
|
||||
/>
|
||||
<div className="assets-edit-actions">
|
||||
<button className="assets-btn assets-btn-primary" onClick={handleSaveEdit}>保存</button>
|
||||
<button className="assets-btn assets-btn-cancel" onClick={() => setEditing(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image preview */}
|
||||
{isImage && !showOnlinePreview && (
|
||||
<div className="assets-preview-image">
|
||||
<img src={asset.url || ''} alt={asset.name} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
{!asset.url && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-tertiary)' }}>
|
||||
无法加载图片预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Image online preview */}
|
||||
{isImage && showOnlinePreview && (
|
||||
<div className="assets-preview-online">
|
||||
<div className="assets-preview-online-toolbar">
|
||||
<span>在线预览:{asset.name}</span>
|
||||
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
|
||||
关闭预览
|
||||
</button>
|
||||
</div>
|
||||
<img src={asset.url || ''} alt={asset.name} style={{maxWidth:'100%',maxHeight:'500px',objectFit:'contain'}}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video preview */}
|
||||
{isVideo && !showOnlinePreview && (
|
||||
<div className="assets-preview-video">
|
||||
<video controls src={asset.url || ''}>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio preview */}
|
||||
{isAudio && !showOnlinePreview && (
|
||||
<div className="assets-preview-audio">
|
||||
<audio controls src={asset.url || ''}>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Online Preview */}
|
||||
{renderOnlinePreview()}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="assets-modal-actions">
|
||||
{isTextFile && !editing && isTextEditable && (
|
||||
<button className="assets-btn assets-btn-secondary" onClick={handleStartEdit}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{!editing && (
|
||||
<button className="assets-btn assets-btn-secondary" onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
下载
|
||||
</button>
|
||||
)}
|
||||
{(isTextFile || isImage || isOfficeDoc || isPdf) && !editing && (
|
||||
<button
|
||||
className="assets-btn assets-btn-secondary"
|
||||
onClick={() => setShowOnlinePreview(!showOnlinePreview)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{showOnlinePreview ? '关闭预览' : '在线预览'}
|
||||
</button>
|
||||
)}
|
||||
{asset.category === 'novel' && (
|
||||
<button className="assets-btn assets-btn-primary" onClick={() => navigate(`/assets/novel/${asset.id}`)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
阅读
|
||||
</button>
|
||||
)}
|
||||
<button className="assets-btn assets-btn-danger" onClick={() => { if (window.confirm('确定删除该资产?')) { onDelete(asset.id); onClose(); } }}>
|
||||
<TrashIcon />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Asset Modal
|
||||
interface CreateAssetModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const CreateAssetModal: React.FC<CreateAssetModalProps> = ({ onClose, onCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [fileType, setFileType] = useState('txt');
|
||||
const [content, setContent] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await assetsAPI.createAsset({
|
||||
name: name.trim(),
|
||||
category: 'file',
|
||||
file_type: fileType,
|
||||
content: content,
|
||||
description: content.slice(0, 100),
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('创建失败', err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assets-modal-overlay" onClick={onClose}>
|
||||
<div className="assets-modal assets-create-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="assets-modal-header">
|
||||
<h3 className="assets-modal-title">创建资产</h3>
|
||||
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
|
||||
</div>
|
||||
<div className="assets-modal-body">
|
||||
<div className="form-section">
|
||||
<label className="form-label">名称 <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="请输入资产名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">文件类型</label>
|
||||
<select className="form-input" value={fileType} onChange={e => setFileType(e.target.value)}>
|
||||
<option value="txt">TXT</option>
|
||||
<option value="word">Word</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="ppt">PPT</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">内容</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
placeholder="输入文件内容..."
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="form-submit-btn"
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || creating}
|
||||
>
|
||||
{creating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Main AssetsPage Component =====
|
||||
const AssetsPage: React.FC = () => {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [subCategory, setSubCategory] = useState<string>('all');
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [categories, setCategories] = useState<AssetCategoryDef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchAssets = async (category?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const catParam = category && category !== 'all' ? category : undefined;
|
||||
const res = await assetsAPI.getAssets(catParam);
|
||||
// getAssets 返回分页格式 {total, page, page_size, items}
|
||||
const rawItems = (res.data as any)?.items || res.data || [];
|
||||
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
|
||||
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
|
||||
...item,
|
||||
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
|
||||
fileType: item.fileType || item.file_type || 'other',
|
||||
}));
|
||||
|
||||
// 按id去重
|
||||
const seen = new Set<string>();
|
||||
const deduped = normalizedAssets.filter(a => {
|
||||
if (seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
});
|
||||
setAssets(deduped);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORIES: AssetCategoryDef[] = [
|
||||
{ id: 'file', name: '文件', type: 'file', color: '#5B5FED', icon: 'file', isDefault: true },
|
||||
{ id: 'image', name: '图片', type: 'image', color: '#10B981', icon: 'image', isDefault: true },
|
||||
{ id: 'video', name: '视频', type: 'video', color: '#EF4444', icon: 'video', isDefault: true },
|
||||
{ id: 'audio', name: '音频', type: 'audio', color: '#8B5CF6', icon: 'audio', isDefault: true },
|
||||
{ id: 'novel', name: '小说', type: 'novel', color: '#F59E0B', icon: 'novel', isDefault: true },
|
||||
{ id: 'other', name: '其他', type: 'other', color: '#64748B', icon: 'other', isDefault: true },
|
||||
{ id: 'custom', name: '自定义', type: 'custom', color: '#EC4899', icon: 'custom', isDefault: true },
|
||||
];
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await assetsAPI.getCategories();
|
||||
const apiCats = res.data || [];
|
||||
const merged: AssetCategoryDef[] = [...apiCats];
|
||||
for (const def of DEFAULT_CATEGORIES) {
|
||||
if (!merged.some((c: any) => c.type === def.type)) {
|
||||
merged.push(def);
|
||||
}
|
||||
}
|
||||
setCategories(merged);
|
||||
} catch (err) {
|
||||
console.error('分类加载失败', err);
|
||||
setCategories(DEFAULT_CATEGORIES);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
fetchAssets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
|
||||
}, [activeCategory]);
|
||||
|
||||
// Filter by sub-category for files
|
||||
const filtered = (() => {
|
||||
// 如果是文件分类,先确保只显示文件类资产
|
||||
let base = activeCategory === 'all' ? assets : assets.filter(a => a.category === activeCategory);
|
||||
// 在文件分类下,按子分类再筛选
|
||||
if (activeCategory === 'file' && subCategory !== 'all') {
|
||||
base = base.filter(a => a.fileType === subCategory);
|
||||
}
|
||||
return base;
|
||||
})();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await assetsAPI.deleteAsset(id);
|
||||
setAssets(prev => prev.filter(a => a.id !== id));
|
||||
} catch (err) {
|
||||
console.error('删除失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
const showFileSubTabs = activeCategory === 'file';
|
||||
|
||||
return (
|
||||
<div className="assets-page">
|
||||
<div className="assets-header">
|
||||
<h1 className="assets-title">我的资产</h1>
|
||||
<span className="assets-count">{filtered.length} 项</span>
|
||||
<button className="assets-create-btn" onClick={() => setShowCreate(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="assets-tabs">
|
||||
<button className={`assets-tab ${activeCategory === 'all' ? 'active' : ''}`} onClick={() => { setActiveCategory('all'); setSubCategory('all'); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
全部
|
||||
</button>
|
||||
{categories.length > 0 ? categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`assets-tab ${activeCategory === cat.type ? 'active' : ''}`}
|
||||
onClick={() => { setActiveCategory(cat.type); setSubCategory('all'); }}
|
||||
style={activeCategory === cat.type ? { '--tab-color': cat.color, '--tab-bg': cat.color + '18' } as React.CSSProperties : undefined}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{cat.type === 'file' && <><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></>}
|
||||
{cat.type === 'novel' && <><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></>}
|
||||
{cat.type === 'image' && <><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></>}
|
||||
{cat.type === 'video' && <><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></>}
|
||||
{cat.type === 'audio' && <><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></>}
|
||||
{cat.type === 'other' && <><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></>}
|
||||
{cat.type === 'custom' && <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>}
|
||||
</svg>
|
||||
{cat.name}
|
||||
</button>
|
||||
)) : (
|
||||
<>
|
||||
<button className={`assets-tab ${activeCategory === 'file' ? 'active' : ''}`} onClick={() => { setActiveCategory('file'); setSubCategory('all'); }} style={activeCategory === 'file' ? { '--tab-color': '#5B5FED', '--tab-bg': '#5B5FED18' } as React.CSSProperties : undefined}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
文件
|
||||
</button>
|
||||
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#10B981', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
图片
|
||||
</button>
|
||||
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#EF4444', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
|
||||
视频
|
||||
</button>
|
||||
<button className={`assets-tab ${activeCategory === 'audio' ? 'active' : ''}`} onClick={() => { setActiveCategory('audio'); setSubCategory('all'); }} style={activeCategory === 'audio' ? { '--tab-color': '#8B5CF6', '--tab-bg': '#8B5CF618' } as React.CSSProperties : undefined}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
音频
|
||||
</button>
|
||||
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#F59E0B', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
小说
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File sub-tabs */}
|
||||
{showFileSubTabs && (
|
||||
<div className="assets-subtabs">
|
||||
<button className={`assets-subtab ${subCategory === 'all' ? 'active' : ''}`} onClick={() => setSubCategory('all')}>全部文件</button>
|
||||
{(['word', 'excel', 'ppt', 'pdf', 'txt', 'md', 'doc', 'other'] as AssetFileType[]).map(ft => (
|
||||
<button key={ft} className={`assets-subtab ${subCategory === ft ? 'active' : ''}`} onClick={() => setSubCategory(ft)}>
|
||||
{fileTypeLabels[ft].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset list */}
|
||||
<div className="assets-list">
|
||||
{loading ? (
|
||||
<div className="assets-loading">
|
||||
<div className="audit-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="assets-empty">
|
||||
<p>加载失败:{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="assets-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||
<p>暂无资产</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`assets-grid ${activeCategory === 'novel' ? 'novel-grid' : ''}`}>
|
||||
{filtered.map(asset => {
|
||||
if (asset.category === 'novel') {
|
||||
return (
|
||||
<div key={asset.id} className="asset-novel-card" onClick={() => setSelectedAsset(asset)}>
|
||||
<div className="novel-card-cover">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#F59E0B', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
|
||||
<span className="novel-card-badge">小说</span>
|
||||
</div>
|
||||
<div className="novel-card-info">
|
||||
<h3 className="novel-card-title">{asset.name}</h3>
|
||||
<p className="novel-card-desc">{asset.description}</p>
|
||||
<div className="novel-card-meta">
|
||||
<span>{asset.chapters?.length || 0} 章</span>
|
||||
<span>·</span>
|
||||
<span>{asset.chapters?.reduce((s, c) => s + c.wordCount, 0).toLocaleString() || 0} 字</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={asset.id} className="asset-card" onClick={() => setSelectedAsset(asset)}>
|
||||
<div className="asset-card-icon">
|
||||
{asset.category === 'file' && (
|
||||
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
|
||||
{getFileTypeInfo(asset.fileType).label}
|
||||
</span>
|
||||
)}
|
||||
{asset.category === 'image' && (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
)}
|
||||
{asset.category === 'video' && (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
|
||||
)}
|
||||
{asset.category === 'audio' && (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-card-body">
|
||||
<h4 className="asset-card-name">{asset.name}</h4>
|
||||
{asset.description && <p className="asset-card-desc">{asset.description}</p>}
|
||||
<div className="asset-card-footer">
|
||||
{asset.size && <span className="asset-card-size">{formatSize(asset.size)}</span>}
|
||||
<span className="asset-card-date">{formatDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{selectedAsset && (
|
||||
<PreviewModal
|
||||
asset={selectedAsset}
|
||||
onClose={() => setSelectedAsset(null)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<CreateAssetModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetsPage;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,704 @@
|
||||
/* ===== Landing Page Styles ===== */
|
||||
|
||||
.landing-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--color-bg-primary);
|
||||
z-index: var(--z-base);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.landing-toast {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
padding: 10px 24px;
|
||||
background: var(--color-text-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideDown var(--transition-normal);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.landing-section-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.landing-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.landing-header-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xl);
|
||||
}
|
||||
|
||||
.landing-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.landing-nav-links {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.landing-nav-links a {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.landing-nav-links a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
.landing-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.landing-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 2px 12px rgba(91, 95, 237, 0.35);
|
||||
}
|
||||
|
||||
.landing-btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
border: 1.5px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-btn-outline:hover {
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.landing-btn-lg {
|
||||
padding: 12px 28px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* ===== Hero ===== */
|
||||
.landing-hero {
|
||||
padding: 80px 0 72px;
|
||||
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
|
||||
}
|
||||
|
||||
.landing-hero-content {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.hero-accent {
|
||||
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 640px;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3xl);
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Carousel ===== */
|
||||
.landing-banners {
|
||||
padding: var(--space-3xl) 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: var(--space-sm) 0 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
min-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.carousel-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3xl) var(--space-3xl);
|
||||
border-radius: var(--radius-xl);
|
||||
min-height: 200px;
|
||||
color: #FFFFFF;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-card-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.carousel-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.carousel-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.carousel-subtitle {
|
||||
font-size: var(--text-base);
|
||||
opacity: 0.9;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.carousel-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 8px 20px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
border-radius: var(--radius-md);
|
||||
color: #FFFFFF;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.carousel-cta:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
border-color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.carousel-card-visual {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visual-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.visual-circle.c1 {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.visual-circle.c2 {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
top: 50px;
|
||||
right: 50px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.visual-circle.c3 {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* Dots */
|
||||
.carousel-dots {
|
||||
position: absolute;
|
||||
bottom: var(--space-lg);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.4);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
background: #FFFFFF;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.carousel-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.9);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover {
|
||||
background: #FFFFFF;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.carousel-arrow-left {
|
||||
left: var(--space-md);
|
||||
}
|
||||
|
||||
.carousel-arrow-right {
|
||||
right: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Market ===== */
|
||||
.landing-market {
|
||||
padding: var(--space-3xl) 0 var(--space-4xl);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.market-filter-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #FFFFFF;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.market-app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.market-app-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.market-app-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.app-card-cover {
|
||||
height: 120px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.app-card-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-card-price {
|
||||
position: absolute;
|
||||
top: var(--space-md);
|
||||
left: var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.app-card-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.app-card-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.app-card-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #F59E0B;
|
||||
}
|
||||
|
||||
.app-card-rating svg {
|
||||
fill: #F59E0B;
|
||||
}
|
||||
|
||||
.market-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-2xl);
|
||||
}
|
||||
|
||||
/* ===== Features ===== */
|
||||
.landing-features {
|
||||
padding: var(--space-4xl) 0;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: var(--space-2xl);
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== CTA ===== */
|
||||
.landing-cta {
|
||||
padding: var(--space-4xl) 0;
|
||||
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.cta-desc {
|
||||
font-size: var(--text-lg);
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin: 0 0 var(--space-2xl);
|
||||
}
|
||||
|
||||
.landing-cta .landing-btn-primary {
|
||||
background: #FFFFFF;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.landing-cta .landing-btn-primary:hover {
|
||||
background: #F0EEFF;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.landing-footer {
|
||||
padding: var(--space-3xl) 0 var(--space-xl);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2xl);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: var(--space-4xl);
|
||||
}
|
||||
|
||||
.footer-col h4 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.footer-col a {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-col a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
padding-top: var(--space-xl);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.landing-nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.carousel-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.carousel-card-visual {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.market-app-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mockAdBanners, mockApps } from '@/mock/data';
|
||||
import type { AdBanner, MarketApp } from '@/types';
|
||||
import './LandingPage.css';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
|
||||
const typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const banners = mockAdBanners.filter(b => b.status === 'active');
|
||||
const publishedApps = mockApps.filter(a => a.status === 'published');
|
||||
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
|
||||
const [toastMsg, setToastMsg] = useState('');
|
||||
|
||||
const filteredApps = appTypeFilter
|
||||
? publishedApps.filter(a => a.type === appTypeFilter)
|
||||
: publishedApps;
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToastMsg(msg);
|
||||
setTimeout(() => setToastMsg(''), 2000);
|
||||
};
|
||||
|
||||
const handleAppClick = (appName: string) => {
|
||||
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
|
||||
setTimeout(() => navigate('/login'), 800);
|
||||
};
|
||||
|
||||
// Auto rotate carousel
|
||||
const nextSlide = useCallback(() => {
|
||||
if (banners.length === 0) return;
|
||||
setCurrentSlide(prev => (prev + 1) % banners.length);
|
||||
}, [banners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (banners.length <= 1 || isPaused) return;
|
||||
const timer = setInterval(nextSlide, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [banners.length, isPaused, nextSlide]);
|
||||
|
||||
const goToSlide = (idx: number) => setCurrentSlide(idx);
|
||||
|
||||
const getGradient = (app: MarketApp): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
||||
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
};
|
||||
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
{/* Toast notification */}
|
||||
{toastMsg && (
|
||||
<div className="landing-toast">{toastMsg}</div>
|
||||
)}
|
||||
|
||||
{/* Hero / Nav bar */}
|
||||
<header className="landing-header">
|
||||
<div className="landing-header-inner">
|
||||
<div className="landing-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
<span className="brand-name">Qihu-AIFlow</span>
|
||||
</div>
|
||||
<nav className="landing-nav-links">
|
||||
<a href="#market">应用市场</a>
|
||||
<a href="#features">平台能力</a>
|
||||
</nav>
|
||||
<div className="landing-header-actions">
|
||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
|
||||
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="landing-hero">
|
||||
<div className="landing-hero-content">
|
||||
<h1 className="hero-title">
|
||||
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
|
||||
</h1>
|
||||
<p className="hero-desc">
|
||||
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
|
||||
让每个人都能轻松搭建属于自己的AI自动化SOP。
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
||||
免费开始使用
|
||||
</button>
|
||||
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
|
||||
浏览应用市场
|
||||
</button>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">3,500+</span>
|
||||
<span className="stat-label">活跃用户</span>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">120+</span>
|
||||
<span className="stat-label">应用/Skills</span>
|
||||
</div>
|
||||
<div className="hero-stat">
|
||||
<span className="stat-number">99.8%</span>
|
||||
<span className="stat-label">服务可用率</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Carousel Section */}
|
||||
{banners.length > 0 && (
|
||||
<section className="landing-banners">
|
||||
<div className="landing-section-inner">
|
||||
<h2 className="section-title">推荐应用</h2>
|
||||
<div
|
||||
className="carousel-container"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
|
||||
{banners.map(banner => (
|
||||
<div key={banner.id} className="carousel-slide">
|
||||
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<div className="carousel-card-content">
|
||||
<div className="carousel-badge">推荐</div>
|
||||
<h3 className="carousel-title">{banner.title}</h3>
|
||||
{banner.linkAppName && (
|
||||
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
|
||||
)}
|
||||
<button
|
||||
className="carousel-cta"
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
查看详情
|
||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div className="carousel-card-visual">
|
||||
<div className="visual-circle c1"/>
|
||||
<div className="visual-circle c2"/>
|
||||
<div className="visual-circle c3"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Dots */}
|
||||
{banners.length > 1 && (
|
||||
<div className="carousel-dots">
|
||||
{banners.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`carousel-dot ${idx === currentSlide ? 'active' : ''}`}
|
||||
onClick={() => goToSlide(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Arrows */}
|
||||
{banners.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="carousel-arrow carousel-arrow-left"
|
||||
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
|
||||
>
|
||||
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
|
||||
</button>
|
||||
<button
|
||||
className="carousel-arrow carousel-arrow-right"
|
||||
onClick={() => nextSlide()}
|
||||
>
|
||||
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Market Preview Section */}
|
||||
<section className="landing-market" id="market">
|
||||
<div className="landing-section-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">应用市场</h2>
|
||||
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="market-filter-tabs">
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === '' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('')}
|
||||
>全部</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'skill' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('skill')}
|
||||
>Skills</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'agent' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('agent')}
|
||||
>Agent</button>
|
||||
<button
|
||||
className={`filter-tab ${appTypeFilter === 'mcp' ? 'active' : ''}`}
|
||||
onClick={() => setAppTypeFilter('mcp')}
|
||||
>MCP</button>
|
||||
</div>
|
||||
|
||||
{/* App Grid */}
|
||||
<div className="market-app-grid">
|
||||
{filteredApps.map(app => (
|
||||
<div key={app.id} className="market-app-card" onClick={() => handleAppClick(app.name)}>
|
||||
<div className="app-card-cover" style={{ background: getGradient(app) }}>
|
||||
<span className="app-card-type-badge" style={{ background: typeColors[app.type] }}>
|
||||
{typeLabels[app.type]}
|
||||
</span>
|
||||
{app.price > 0 && (
|
||||
<span className="app-card-price">{app.price} 元</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="app-card-body">
|
||||
<h4 className="app-card-name">{app.name}</h4>
|
||||
<p className="app-card-desc">{app.description}</p>
|
||||
<div className="app-card-meta">
|
||||
<span className="app-card-rating">
|
||||
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
|
||||
{app.ratingAvg > 0 ? app.ratingAvg : '-'}
|
||||
</span>
|
||||
<span className="app-card-installs">{app.installCount.toLocaleString()} 次安装</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="market-more">
|
||||
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
|
||||
登录查看更多应用
|
||||
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="landing-features" id="features">
|
||||
<div className="landing-section-inner">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">平台核心能力</h2>
|
||||
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
|
||||
</div>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">多模型AI对话</h3>
|
||||
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">私有知识库</h3>
|
||||
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">应用市场</h3>
|
||||
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">Token驾驶舱</h3>
|
||||
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">IM频道绑定</h3>
|
||||
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">
|
||||
<SVGIcon size={28}><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon>
|
||||
</div>
|
||||
<h3 className="feature-title">自动化任务编排</h3>
|
||||
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="landing-cta">
|
||||
<div className="landing-section-inner">
|
||||
<h2 className="cta-title">准备好开始了吗?</h2>
|
||||
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
|
||||
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
|
||||
免费注册,立即体验
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="landing-footer">
|
||||
<div className="landing-section-inner">
|
||||
<div className="footer-content">
|
||||
<div className="footer-brand">
|
||||
<div className="landing-brand">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
|
||||
AI智能自动化PaaS平台
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<div className="footer-col">
|
||||
<h4>产品</h4>
|
||||
<a>AI聊天</a>
|
||||
<a>知识库</a>
|
||||
<a>应用市场</a>
|
||||
</div>
|
||||
<div className="footer-col">
|
||||
<h4>支持</h4>
|
||||
<a>帮助文档</a>
|
||||
<a>API参考</a>
|
||||
<a>联系我们</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>Qihu-AIFlow 2026. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,342 @@
|
||||
/* ===== Login Page ===== */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg-decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.bg-circle.c1 {
|
||||
width: 600px; height: 600px;
|
||||
background: var(--color-accent);
|
||||
top: -200px; right: -100px;
|
||||
}
|
||||
|
||||
.bg-circle.c2 {
|
||||
width: 400px; height: 400px;
|
||||
background: #8B8DFF;
|
||||
bottom: -100px; left: -50px;
|
||||
}
|
||||
|
||||
.bg-circle.c3 {
|
||||
width: 300px; height: 300px;
|
||||
background: var(--color-accent);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.login-container {
|
||||
display: flex;
|
||||
width: 900px;
|
||||
min-height: 560px;
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
overflow: hidden;
|
||||
z-index: var(--z-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Brand Panel */
|
||||
.login-brand-panel {
|
||||
width: 400px;
|
||||
background: linear-gradient(135deg, #2D2B55 0%, #1A1A2E 50%, #2D2B55 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-brand-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 300px; height: 300px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(91,95,237,0.3) 0%, transparent 70%);
|
||||
top: -50px; right: -50px;
|
||||
}
|
||||
|
||||
.login-brand-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 200px; height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(139,141,255,0.2) 0%, transparent 70%);
|
||||
bottom: -30px; left: -30px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: var(--text-base);
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.brand-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brand-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.bf-icon {
|
||||
color: #6C63FF;
|
||||
font-weight: 700;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Form Panel */
|
||||
.login-form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4xl);
|
||||
}
|
||||
|
||||
.login-form-inner {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2xl);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-tertiary);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-tab.active {
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.login-submit-btn {
|
||||
height: 46px;
|
||||
background: linear-gradient(135deg, #5B5FED, #6C63FF);
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-submit-btn:hover {
|
||||
background: linear-gradient(135deg, #4A4ECC, #5B5FED);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-submit-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-submit-btn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Register Method Tabs */
|
||||
.register-method-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.register-method-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.register-method-tab:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.register-method-tab.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SMS Row */
|
||||
.sms-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.sms-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sms-btn {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-accent);
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.sms-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sms-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Required Marker */
|
||||
.required {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
flex-direction: column;
|
||||
width: 90vw;
|
||||
min-height: auto;
|
||||
}
|
||||
.login-brand-panel {
|
||||
width: 100%;
|
||||
padding: var(--space-3xl) var(--space-2xl);
|
||||
}
|
||||
.login-form-panel {
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
.brand-features {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { authAPI } from '@/api';
|
||||
import type { User } from '@/types';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Register state
|
||||
const [registerMethod, setRegisterMethod] = useState<'phone' | 'email'>('phone');
|
||||
const [regPhone, setRegPhone] = useState('');
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regUsername, setRegUsername] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [smsSent, setSmsSent] = useState(false);
|
||||
const [smsCountdown, setSmsCountdown] = useState(0);
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [emailCountdown, setEmailCountdown] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Login state
|
||||
const [loginAccount, setLoginAccount] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
const startCountdown = (setter: (v: number) => void) => {
|
||||
let count = 60;
|
||||
setter(count);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
count--;
|
||||
setter(count);
|
||||
if (count <= 0 && timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleSendSms = () => {
|
||||
if (!regPhone.trim() || smsCountdown > 0) return;
|
||||
setSmsSent(true);
|
||||
startCountdown(setSmsCountdown);
|
||||
};
|
||||
|
||||
const handleSendEmailCode = () => {
|
||||
if (!regEmail.trim() || emailCountdown > 0) return;
|
||||
setEmailSent(true);
|
||||
startCountdown(setEmailCountdown);
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setErrorMsg('');
|
||||
if (!loginAccount.trim() || !loginPassword.trim()) {
|
||||
setErrorMsg('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await authAPI.login(loginAccount, loginPassword);
|
||||
const { access_token, user: userData } = res.data;
|
||||
localStorage.setItem('aiflow_token', access_token);
|
||||
const userObj: User = {
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || '',
|
||||
avatar: userData.avatar || '',
|
||||
role: userData.role,
|
||||
status: userData.status,
|
||||
realNameStatus: userData.real_name_status || 'unverified',
|
||||
createdAt: userData.created_at || new Date().toISOString(),
|
||||
membership: userData.membership_level > 0 ? {
|
||||
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
|
||||
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
||||
level: userData.membership_level,
|
||||
expireAt: userData.membership_expire_at || '',
|
||||
autoRenew: userData.auto_renew || false,
|
||||
} : undefined,
|
||||
};
|
||||
login(userObj);
|
||||
navigate(userData.role === 'admin' ? '/admin' : '/chat');
|
||||
} catch (e: any) {
|
||||
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
|
||||
const status = e.status || e.response?.status || 0;
|
||||
const msg = e.detail || e.message || '';
|
||||
if (status === 403) {
|
||||
setErrorMsg('账号已被禁用,请联系管理员');
|
||||
} else if (status === 401) {
|
||||
// 后端的detail精确指明了原因:"用户名不存在" 或 "密码错误"
|
||||
setErrorMsg(msg || '用户名或密码错误');
|
||||
} else if (status === 0 || msg.includes('NetworkError') || msg.includes('Failed to fetch')) {
|
||||
setErrorMsg('网络连接失败,请检查网络');
|
||||
} else {
|
||||
setErrorMsg(msg || '登录失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
setErrorMsg('');
|
||||
if (!regUsername.trim() || !regPassword.trim() || regPassword.length < 6) {
|
||||
setErrorMsg('用户名和密码为必填(密码至少6位)');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = { username: regUsername, password: regPassword };
|
||||
if (registerMethod === 'phone') data.phone = regPhone;
|
||||
else data.email = regEmail;
|
||||
const res = await authAPI.register(data);
|
||||
const { access_token, user: userData } = res.data;
|
||||
localStorage.setItem('aiflow_token', access_token);
|
||||
const userObj: User = {
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || '',
|
||||
avatar: userData.avatar || '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
realNameStatus: 'unverified',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
login(userObj);
|
||||
navigate('/chat');
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || '注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && activeTab === 'login') handleLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-bg-decor">
|
||||
<div className="bg-circle c1" />
|
||||
<div className="bg-circle c2" />
|
||||
<div className="bg-circle c3" />
|
||||
<div className="bg-grid" />
|
||||
</div>
|
||||
|
||||
<div className="login-container">
|
||||
<div className="login-brand-panel">
|
||||
<div className="brand-content">
|
||||
<div className="brand-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="white"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#5B5FED" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="#5B5FED" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="brand-title">AIFlow</h1>
|
||||
<p className="brand-desc">智能自动化PaaS平台</p>
|
||||
<div className="brand-features">
|
||||
<div className="brand-feature"><span className="bf-icon">✓</span><span>一站式AI自动化SOP</span></div>
|
||||
<div className="brand-feature"><span className="bf-icon">✓</span><span>多模型自由切换</span></div>
|
||||
<div className="brand-feature"><span className="bf-icon">✓</span><span>知识库 + 应用市场</span></div>
|
||||
<div className="brand-feature"><span className="bf-icon">✓</span><span>Token消耗实时掌控</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-form-panel">
|
||||
<div className="login-form-inner">
|
||||
<div className="login-tabs">
|
||||
<button className={`login-tab ${activeTab === 'login' ? 'active' : ''}`} onClick={() => setActiveTab('login')}>登录</button>
|
||||
<button className={`login-tab ${activeTab === 'register' ? 'active' : ''}`} onClick={() => setActiveTab('register')}>注册</button>
|
||||
</div>
|
||||
|
||||
{errorMsg && <div className="form-error">{errorMsg}</div>}
|
||||
|
||||
{activeTab === 'login' ? (
|
||||
<div className="login-form" onKeyDown={handleKeyDown}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名</label>
|
||||
<input type="text" className="form-input" placeholder="请输入用户名" value={loginAccount} onChange={e => setLoginAccount(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码</label>
|
||||
<input type="password" className="form-input" placeholder="请输入密码" value={loginPassword} onChange={e => setLoginPassword(e.target.value)} />
|
||||
</div>
|
||||
<button className="login-submit-btn" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="login-form">
|
||||
<div className="register-method-tabs">
|
||||
<button className={`register-method-tab ${registerMethod === 'phone' ? 'active' : ''}`} onClick={() => setRegisterMethod('phone')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
|
||||
手机号注册
|
||||
</button>
|
||||
<button className={`register-method-tab ${registerMethod === 'email' ? 'active' : ''}`} onClick={() => setRegisterMethod('email')}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
邮箱注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{registerMethod === 'phone' ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">手机号</label>
|
||||
<input type="tel" className="form-input" placeholder="请输入手机号码" value={regPhone} onChange={e => setRegPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">短信验证码</label>
|
||||
<div className="sms-row">
|
||||
<input type="text" className="form-input sms-input" placeholder="请输入验证码" value={smsCode} onChange={e => setSmsCode(e.target.value)} />
|
||||
<button className="sms-btn" onClick={handleSendSms} disabled={!regPhone.trim() || smsCountdown > 0}>
|
||||
{smsCountdown > 0 ? `${smsCountdown}秒后重发` : smsSent ? '重新发送' : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">邮箱</label>
|
||||
<input type="email" className="form-input" placeholder="请输入邮箱地址" value={regEmail} onChange={e => setRegEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">邮箱验证码</label>
|
||||
<div className="sms-row">
|
||||
<input type="text" className="form-input sms-input" placeholder="请输入邮箱验证码" value={emailCode} onChange={e => setEmailCode(e.target.value)} />
|
||||
<button className="sms-btn" onClick={handleSendEmailCode} disabled={!regEmail.trim() || emailCountdown > 0}>
|
||||
{emailCountdown > 0 ? `${emailCountdown}秒后重发` : emailSent ? '重新发送' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名 <span className="required">*</span></label>
|
||||
<input type="text" className="form-input" placeholder="请输入用户名" value={regUsername} onChange={e => setRegUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码 <span className="required">*</span></label>
|
||||
<input type="password" className="form-input" placeholder="请设置密码(至少6位)" value={regPassword} onChange={e => setRegPassword(e.target.value)} />
|
||||
</div>
|
||||
<button className="login-submit-btn" onClick={handleRegister} disabled={loading}>
|
||||
{loading ? '注册中...' : '注 册'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
/* ===== NotificationPage Styles ===== */
|
||||
.notification-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/* ----- Header ----- */
|
||||
.notification-page-header {
|
||||
padding: var(--space-3xl) var(--space-4xl) var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.notification-page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-unread-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 12px;
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ----- Filter Tabs ----- */
|
||||
.notification-filter-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
background: var(--color-bg-primary);
|
||||
padding: var(--space-xs);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.notif-filter-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-filter-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.notif-filter-tab.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--color-bg-active);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notif-filter-tab.active .notif-filter-count {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ----- Mark All Read Button ----- */
|
||||
.mark-all-read-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mark-all-read-btn:hover {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ----- Notification List ----- */
|
||||
.notification-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-lg) var(--space-4xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ----- Notification Item ----- */
|
||||
.notification-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* Unread dot */
|
||||
.notification-unread-dot {
|
||||
position: absolute;
|
||||
top: var(--space-lg);
|
||||
left: var(--space-sm);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Type Icon */
|
||||
.notification-type-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.notification-content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.notification-type-label {
|
||||
display: inline-block;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Time */
|
||||
.notification-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ----- Empty State ----- */
|
||||
.notification-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5xl);
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-empty-icon {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.notification-empty-text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ----- Responsive ----- */
|
||||
@media (max-width: 768px) {
|
||||
.notification-page-header {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
.notification-header-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.notification-filter-tabs {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
.notif-filter-tab {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.notification-list {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
.notification-item {
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.notification-time {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification-title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.notification-type-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { mockNotifications } from '@/mock/data';
|
||||
import type { Notification } from '@/types';
|
||||
import './NotificationPage.css';
|
||||
|
||||
// ===== SVG Icons for notification types =====
|
||||
const typeIconMap: Record<Notification['type'], { icon: JSX.Element; color: string; bg: string }> = {
|
||||
announcement: {
|
||||
color: '#5B5FED',
|
||||
bg: '#EEEDFF',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
reminder: {
|
||||
color: '#E8922C',
|
||||
bg: '#FEF4E8',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
renewal: {
|
||||
color: '#3B82F6',
|
||||
bg: '#EBF2FE',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
order: {
|
||||
color: '#219B5E',
|
||||
bg: '#E6F7EE',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
update: {
|
||||
color: '#8B5CF6',
|
||||
bg: '#F3F0FF',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const typeLabelMap: Record<Notification['type'] | 'all', string> = {
|
||||
all: '全部',
|
||||
announcement: '公告',
|
||||
reminder: '提醒',
|
||||
renewal: '续费',
|
||||
order: '订单',
|
||||
update: '更新',
|
||||
};
|
||||
|
||||
// ===== Helper: format relative time =====
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const date = new Date(dateStr).getTime();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 0) return '刚刚';
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
if (days < 7) return `${days}天前`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`;
|
||||
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
// ===== NotificationPage =====
|
||||
const NotificationPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [activeFilter, setActiveFilter] = useState<Notification['type'] | 'all'>('all');
|
||||
const [notifications, setNotifications] = useState<Notification[]>(() =>
|
||||
mockNotifications.filter(n => n.userId === 'u1')
|
||||
);
|
||||
|
||||
const unreadCount = useMemo(() => notifications.filter(n => !n.isRead).length, [notifications]);
|
||||
|
||||
const filteredNotifications = useMemo(() => {
|
||||
if (activeFilter === 'all') return notifications;
|
||||
return notifications.filter(n => n.type === activeFilter);
|
||||
}, [notifications, activeFilter]);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: notifications.length };
|
||||
notifications.forEach(n => {
|
||||
counts[n.type] = (counts[n.type] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [notifications]);
|
||||
|
||||
const handleMarkAsRead = (id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, isRead: true } : n))
|
||||
);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const filterKeys = ['all', 'announcement', 'reminder', 'renewal', 'order', 'update'] as const;
|
||||
|
||||
return (
|
||||
<div className="notification-page">
|
||||
{/* Header */}
|
||||
<div className="notification-page-header">
|
||||
<div className="notification-title-row">
|
||||
<h1 className="notification-page-title">消息通知</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-unread-badge">{unreadCount}条未读</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notification-header-actions">
|
||||
{/* Filter Tabs */}
|
||||
<div className="notification-filter-tabs">
|
||||
{filterKeys.map(key => (
|
||||
<button
|
||||
key={key}
|
||||
className={`notif-filter-tab ${activeFilter === key ? 'active' : ''}`}
|
||||
onClick={() => setActiveFilter(key)}
|
||||
>
|
||||
<span>{typeLabelMap[key]}</span>
|
||||
{typeCounts[key] !== undefined && (
|
||||
<span className="notif-filter-count">{typeCounts[key]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mark All Read */}
|
||||
{unreadCount > 0 && (
|
||||
<button className="mark-all-read-btn" onClick={handleMarkAllAsRead}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
全部标为已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="notification-list">
|
||||
{filteredNotifications.length > 0 ? (
|
||||
filteredNotifications.map(notif => {
|
||||
const typeConfig = typeIconMap[notif.type];
|
||||
return (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`notification-item ${!notif.isRead ? 'unread' : ''}`}
|
||||
onClick={() => handleMarkAsRead(notif.id)}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
{!notif.isRead && <span className="notification-unread-dot" />}
|
||||
|
||||
{/* Type Icon */}
|
||||
<div
|
||||
className="notification-type-icon"
|
||||
style={{ backgroundColor: typeConfig.bg, color: typeConfig.color }}
|
||||
>
|
||||
{typeConfig.icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="notification-content">
|
||||
<div className="notification-content-header">
|
||||
<span className="notification-type-label" style={{ color: typeConfig.color }}>
|
||||
{typeLabelMap[notif.type]}
|
||||
</span>
|
||||
<h4 className="notification-title">{notif.title}</h4>
|
||||
</div>
|
||||
<p className="notification-body">{notif.content}</p>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="notification-time">{formatRelativeTime(notif.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="notification-empty">
|
||||
<div className="notification-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="notification-empty-text">暂无{activeFilter === 'all' ? '' : typeLabelMap[activeFilter]}通知</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPage;
|
||||
@@ -0,0 +1,469 @@
|
||||
/* ===== Novel Preview Page ===== */
|
||||
.novel-preview-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-primary);
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
.novel-preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: var(--space-lg);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.novel-topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg) var(--space-2xl);
|
||||
background: linear-gradient(135deg, #FFFBEB, #FFF7ED);
|
||||
border-bottom: 1px solid #FDE68A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.novel-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
color: #92400E;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.novel-back-btn:hover {
|
||||
background: #FEF3C7;
|
||||
}
|
||||
|
||||
.novel-intro {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.novel-intro-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: #78350F;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.novel-intro-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: #A16207;
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.novel-intro-stats {
|
||||
font-size: var(--text-xs);
|
||||
color: #B45309;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Body Layout */
|
||||
.novel-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left Sidebar */
|
||||
.novel-sidebar-left {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
overflow-y: auto;
|
||||
background: #FAFAFA;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.novel-sidebar-title {
|
||||
padding: var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.novel-chapter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.novel-chapter-item {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.novel-chapter-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.novel-chapter-item.active {
|
||||
background: #FEF3C7;
|
||||
}
|
||||
|
||||
.chapter-item-num {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-disabled);
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.novel-chapter-item.active .chapter-item-num {
|
||||
color: #D97706;
|
||||
}
|
||||
|
||||
.chapter-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chapter-item-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chapter-item-count {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
/* Center Content */
|
||||
.novel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3xl) var(--space-4xl);
|
||||
background: #FFFFFF;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.novel-content-header {
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.novel-chapter-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.novel-content-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.novel-paragraph {
|
||||
font-size: var(--text-md);
|
||||
line-height: 2;
|
||||
color: var(--color-text-primary);
|
||||
text-indent: 2em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.novel-content-footer {
|
||||
margin-top: var(--space-3xl);
|
||||
padding-top: var(--space-xl);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.novel-content-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-disabled);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Right Sidebar */
|
||||
.novel-sidebar-right {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
overflow-y: auto;
|
||||
background: #FAFAFA;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.novel-right-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.novel-right-tab {
|
||||
flex: 1;
|
||||
padding: var(--space-md) var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
text-align: center;
|
||||
transition: all var(--transition-fast);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.novel-right-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.novel-right-tab.active {
|
||||
color: #D97706;
|
||||
border-bottom-color: #D97706;
|
||||
}
|
||||
|
||||
.novel-right-content {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* World View */
|
||||
.novel-worldview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.worldview-summary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.worldview-section h4 {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.worldview-section ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.worldview-section li {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
padding-left: var(--space-md);
|
||||
position: relative;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.worldview-section li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #D97706;
|
||||
}
|
||||
|
||||
.worldview-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.timeline-era {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #D97706;
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-event {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Characters */
|
||||
.novel-characters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.character-card {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, #F59E0B, #FBBF24);
|
||||
color: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.character-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.character-role {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.character-role.main {
|
||||
background: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.character-role.villain {
|
||||
background: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
.character-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.character-rels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.character-rel-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-accent-subtle);
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Hooks & Anchors */
|
||||
.novel-hooks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xl);
|
||||
}
|
||||
|
||||
.hooks-section h4 {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.hook-item, .anchor-item {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hook-item svg {
|
||||
color: #F59E0B;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.anchor-item svg {
|
||||
color: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.novel-sidebar-right { width: 240px; min-width: 240px; }
|
||||
.novel-sidebar-left { width: 160px; min-width: 160px; }
|
||||
.novel-content { padding: var(--space-2xl); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.novel-body { flex-direction: column; }
|
||||
.novel-sidebar-left { width: 100%; min-width: 0; max-height: 200px; border-right: 0; border-bottom: 1px solid var(--color-border-light); }
|
||||
.novel-sidebar-right { width: 100%; min-width: 0; max-height: 300px; border-left: 0; border-top: 1px solid var(--color-border-light); }
|
||||
.novel-content { flex: 1; padding: var(--space-lg); }
|
||||
.novel-topbar { padding: var(--space-md); }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Asset, NovelChapter } from '@/types';
|
||||
import { mockAssets } from '@/mock/data';
|
||||
import './NovelPreviewPage.css';
|
||||
|
||||
const NovelPreviewPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeChapter, setActiveChapter] = useState<string>('');
|
||||
const [rightTab, setRightTab] = useState<'worldview' | 'characters' | 'hooks'>('worldview');
|
||||
|
||||
const novel: Asset | undefined = mockAssets.find(a => a.id === id && a.category === 'novel');
|
||||
const chapters = novel?.chapters || [];
|
||||
const meta = novel?.novelMeta;
|
||||
const currentChapter: NovelChapter | undefined = chapters.find(c => c.id === (activeChapter || chapters[0]?.id));
|
||||
|
||||
if (!novel) {
|
||||
return (
|
||||
<div className="novel-preview-page">
|
||||
<div className="novel-preview-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<p>未找到该小说</p>
|
||||
<button className="novel-back-btn" onClick={() => navigate('/assets')}>返回资产</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="novel-preview-page">
|
||||
{/* Top: Novel Intro */}
|
||||
<div className="novel-topbar">
|
||||
<button className="novel-back-btn" onClick={() => navigate('/assets')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
</button>
|
||||
<div className="novel-intro">
|
||||
<h1 className="novel-intro-title">{novel.name}</h1>
|
||||
<p className="novel-intro-desc">{novel.description}</p>
|
||||
<div className="novel-intro-stats">
|
||||
<span>{chapters.length} 章</span>
|
||||
<span>·</span>
|
||||
<span>{chapters.reduce((s, c) => s + c.wordCount, 0).toLocaleString()} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="novel-body">
|
||||
{/* Left: Chapter List */}
|
||||
<aside className="novel-sidebar-left">
|
||||
<h3 className="novel-sidebar-title">章节目录</h3>
|
||||
<div className="novel-chapter-list">
|
||||
{chapters.map(ch => (
|
||||
<button
|
||||
key={ch.id}
|
||||
className={`novel-chapter-item ${(activeChapter || chapters[0]?.id) === ch.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveChapter(ch.id)}
|
||||
>
|
||||
<span className="chapter-item-num">{String(ch.sortOrder).padStart(2, '0')}</span>
|
||||
<div className="chapter-item-info">
|
||||
<span className="chapter-item-title">{ch.title}</span>
|
||||
<span className="chapter-item-count">{ch.wordCount.toLocaleString()} 字</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Center: Content */}
|
||||
<main className="novel-content">
|
||||
{currentChapter ? (
|
||||
<>
|
||||
<div className="novel-content-header">
|
||||
<h2 className="novel-chapter-title">{currentChapter.title}</h2>
|
||||
</div>
|
||||
<div className="novel-content-body">
|
||||
{currentChapter.content.split('\n\n').map((para, i) => (
|
||||
<p key={i} className="novel-paragraph">{para}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="novel-content-footer">
|
||||
<span>本章字数:{currentChapter.wordCount.toLocaleString()} 字</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="novel-content-empty">
|
||||
<p>暂无章节内容</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right: World View, Characters, Hooks & Anchors */}
|
||||
<aside className="novel-sidebar-right">
|
||||
<div className="novel-right-tabs">
|
||||
<button
|
||||
className={`novel-right-tab ${rightTab === 'worldview' ? 'active' : ''}`}
|
||||
onClick={() => setRightTab('worldview')}
|
||||
>世界观</button>
|
||||
<button
|
||||
className={`novel-right-tab ${rightTab === 'characters' ? 'active' : ''}`}
|
||||
onClick={() => setRightTab('characters')}
|
||||
>人物</button>
|
||||
<button
|
||||
className={`novel-right-tab ${rightTab === 'hooks' ? 'active' : ''}`}
|
||||
onClick={() => setRightTab('hooks')}
|
||||
>钩子&锚点</button>
|
||||
</div>
|
||||
|
||||
<div className="novel-right-content">
|
||||
{rightTab === 'worldview' && meta?.worldView && (
|
||||
<div className="novel-worldview">
|
||||
<p className="worldview-summary">{meta.worldView.summary}</p>
|
||||
<div className="worldview-section">
|
||||
<h4>世界规则</h4>
|
||||
<ul>
|
||||
{meta.worldView.rules.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="worldview-section">
|
||||
<h4>时间线</h4>
|
||||
<div className="worldview-timeline">
|
||||
{meta.worldView.timeline.map((t, i) => (
|
||||
<div key={i} className="timeline-item">
|
||||
<span className="timeline-era">{t.era}</span>
|
||||
<span className="timeline-event">{t.events}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightTab === 'characters' && meta?.characters && (
|
||||
<div className="novel-characters">
|
||||
{meta.characters.map(char => (
|
||||
<div key={char.id} className="character-card">
|
||||
<div className="character-avatar">{char.name.charAt(0)}</div>
|
||||
<div className="character-info">
|
||||
<div className="character-name-row">
|
||||
<span className="character-name">{char.name}</span>
|
||||
<span className={`character-role ${char.role === '主角' ? 'main' : char.role === '反派' ? 'villain' : ''}`}>{char.role}</span>
|
||||
</div>
|
||||
<p className="character-desc">{char.description}</p>
|
||||
{char.relationships.length > 0 && (
|
||||
<div className="character-rels">
|
||||
{char.relationships.map((r, i) => (
|
||||
<span key={i} className="character-rel-tag">{r}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightTab === 'hooks' && meta && (
|
||||
<div className="novel-hooks">
|
||||
<div className="hooks-section">
|
||||
<h4>故事钩子</h4>
|
||||
{meta.hooks.map((h, i) => (
|
||||
<div key={i} className="hook-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
<span>{h}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hooks-section">
|
||||
<h4>故事锚点</h4>
|
||||
{meta.anchors.map((a, i) => (
|
||||
<div key={i} className="anchor-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span>{a}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NovelPreviewPage;
|
||||
@@ -0,0 +1,995 @@
|
||||
/* ===== Pricing Page ===== */
|
||||
.pricing-page {
|
||||
padding: var(--space-3xl) var(--space-4xl);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pricing-page::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.pricing-page:hover::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.pricing-page:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.pricing-page:hover::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.pricing-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.pricing-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.pricing-subtitle {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Billing Cycle Tabs ===== */
|
||||
.pricing-cycle-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-3xl);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px;
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pricing-cycle-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pricing-cycle-tab.active {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.pricing-cycle-tab:hover:not(.active) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pricing-savings-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== Plans Grid ===== */
|
||||
.pricing-plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-4xl);
|
||||
max-width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Single card centers naturally with auto-fit */
|
||||
@media (min-width: 1025px) {
|
||||
.pricing-plans-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.pricing-plans-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Plan Card ===== */
|
||||
.pricing-plan-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--space-3xl) var(--space-2xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xl);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.pricing-plan--current {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.pricing-plan--popular {
|
||||
border-color: #F59E0B;
|
||||
}
|
||||
|
||||
/* Popular Ribbon */
|
||||
.pricing-popular-ribbon {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: -32px;
|
||||
background: linear-gradient(135deg, #F59E0B, #EF4444);
|
||||
color: #FFFFFF;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 40px;
|
||||
transform: rotate(45deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pricing-popular-ribbon svg {
|
||||
color: #FFFFFF;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pricing-current-badge {
|
||||
position: absolute;
|
||||
top: var(--space-lg);
|
||||
left: var(--space-lg);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Plan Head */
|
||||
.pricing-plan-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.pricing-plan-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pricing-plan-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.pricing-currency {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.pricing-plan-cycle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.pricing-plan-savings {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
background: var(--color-success-light);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.pricing-plan-tokens {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Features */
|
||||
.pricing-plan-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.pricing-feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pricing-feature-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Subscribe Button */
|
||||
.pricing-subscribe-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
transition: all var(--transition-fast);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.pricing-subscribe-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.pricing-btn--current {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pricing-btn--current:hover {
|
||||
background: var(--color-accent-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ===== Section ===== */
|
||||
.pricing-section {
|
||||
margin-bottom: var(--space-4xl);
|
||||
}
|
||||
|
||||
.pricing-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.pricing-section-header svg {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pricing-section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ===== Services Grid ===== */
|
||||
.pricing-services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.pricing-service-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
gap: var(--space-lg);
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-service-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.pricing-service-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pricing-service-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pricing-service-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pricing-service-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pricing-service-price {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pricing-buy-btn {
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
border: 1.5px solid var(--color-accent);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-buy-btn:hover {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* ===== Task Management (expanded service) ===== */
|
||||
.pricing-service-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pricing-service-card.expanded {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pricing-service-card.expanded .pricing-service-main {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pricing-service-card.expanded .pricing-service-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pricing-task-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2xl);
|
||||
padding-top: var(--space-xl);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
animation: slideUp var(--transition-normal);
|
||||
}
|
||||
|
||||
.task-detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.task-detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Task Creation Options */
|
||||
.task-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-option-card {
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-option-card:hover {
|
||||
background: var(--color-accent-subtle);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.task-option-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-option-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Task Triggers */
|
||||
.task-triggers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-trigger-card {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-trigger-card:hover {
|
||||
background: var(--color-accent-subtle);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.task-trigger-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
color: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-trigger-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-trigger-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.task-trigger-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-trigger-example {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-task-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.task-options-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Coupons ===== */
|
||||
.pricing-coupons-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.pricing-coupon-card {
|
||||
display: flex;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-coupon-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.pricing-coupon--used {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pricing-coupon-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl) var(--space-xl);
|
||||
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
|
||||
min-width: 120px;
|
||||
border-right: 2px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.pricing-coupon-value {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 800;
|
||||
color: #D97706;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pricing-coupon-type {
|
||||
font-size: var(--text-xs);
|
||||
color: #B45309;
|
||||
margin-top: var(--space-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pricing-coupon-right {
|
||||
flex: 1;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pricing-coupon-code {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.pricing-coupon-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pricing-coupon-valid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.pricing-coupon-valid svg {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.pricing-coupon-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.pricing-coupon--used .pricing-coupon-status {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.pricing-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-disabled);
|
||||
grid-column: 1 / -1;
|
||||
padding: var(--space-3xl);
|
||||
}
|
||||
|
||||
/* ===== Payment Methods ===== */
|
||||
.pricing-payment-row {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.pricing-payment-method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg) var(--space-2xl);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-payment-method:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pricing-payment-method svg {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.pricing-page {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
.pricing-plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 480px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: var(--space-4xl);
|
||||
}
|
||||
.pricing-services-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-services-grid,
|
||||
.pricing-coupons-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pricing-payment-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.pricing-plan-card {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
.pricing-services-grid-v2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== V2 增值服务卡片(四大类) ===== */
|
||||
.pricing-services-grid-v2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-2xl);
|
||||
gap: var(--space-lg);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-feature-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-price {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-btn {
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
background: var(--color-accent);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-service-card-v2-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== Modal ===== */
|
||||
.pricing-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
.pricing-modal {
|
||||
background: #FFFFFF;
|
||||
border-radius: var(--radius-2xl);
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.pricing-modal-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.pricing-modal-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 var(--space-2xl) 0;
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl) var(--space-md);
|
||||
border: 2px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-card.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pricing-modal-app-type-desc {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pricing-modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.pricing-modal-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pricing-modal-form-group label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pricing-modal-form-group label .required {
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.pricing-modal-form-group input,
|
||||
.pricing-modal-form-group textarea {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pricing-modal-form-group input:focus,
|
||||
.pricing-modal-form-group textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
.pricing-modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pricing-modal-btn-cancel {
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-modal-btn-cancel:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.pricing-modal-btn-confirm {
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pricing-modal-btn-confirm:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.pricing-modal-btn-confirm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { paymentAPI } from '@/api';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import './PricingPage.css';
|
||||
|
||||
// ---- SVG Icons ----
|
||||
const IconCheck = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconService = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
|
||||
<line x1="9" y1="9" x2="15" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="13" x2="13" y2="13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="9" y1="17" x2="11" y2="17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconTag = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconCoupon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" strokeWidth="1.6" strokeDasharray="2 2" />
|
||||
<circle cx="6" cy="12" r="3" stroke="currentColor" strokeWidth="1.2" />
|
||||
<text x="18" y="13.5" textAnchor="middle" fontSize="5" fontWeight="800" fill="currentColor">OFF</text>
|
||||
</svg>
|
||||
);
|
||||
const IconClock = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
|
||||
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconSparkles = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2l1.5 5h5l-4 3 1.5 5-4-3-4 3 1.5-5-4-3h5L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconAppCustom = () => (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
const IconPlatform = () => (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="9" width="20" height="13" rx="2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M6 9V7a6 6 0 0112 0v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16" r="2" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
);
|
||||
const IconDataCustom = () => (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
);
|
||||
const IconPrivateProject = () => (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 21h18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M5 21V7l7-4 7 4v14" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M9 14v7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M15 11v10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconWechat = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 12a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
|
||||
<path d="M15 10.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
|
||||
<path d="M21 13.5c0 3.6-3.7 6.5-8.3 6.5-.8 0-1.5-.1-2.2-.2L7 21l1.3-3.2C6.5 16.5 5 15.1 5 13.5 5 9.9 8.7 7 13.3 7S21 9.9 21 13.5z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconAlipay = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="4" width="20" height="16" rx="3" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M8 14.5c1.5-2.5 3.5-4 5.5-4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M7 12l4-3.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="14" cy="14" r="1" fill="currentColor" />
|
||||
<text x="12" y="17" textAnchor="middle" fontSize="5" fontWeight="700" fill="currentColor">Alipay</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function formatYuan(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
interface ServiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
price: number;
|
||||
features?: string[];
|
||||
assigned_agent_id?: string;
|
||||
}
|
||||
|
||||
interface CouponItem {
|
||||
id: string;
|
||||
code: string;
|
||||
type: string;
|
||||
value: number;
|
||||
min_amount: number;
|
||||
expire_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const SERVICE_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
|
||||
app_custom: { label: '应用定制', icon: <IconAppCustom />, color: '#4F46E5', bg: '#EEF2FF' },
|
||||
platform_proxy: { label: '平台代理', icon: <IconPlatform />, color: '#059669', bg: '#ECFDF5' },
|
||||
data_custom: { label: '数据定制', icon: <IconDataCustom />, color: '#D97706', bg: '#FFFBEB' },
|
||||
private_project: { label: '私有化项目', icon: <IconPrivateProject />, color: '#7C3AED', bg: '#F5F3FF' },
|
||||
};
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [billingCycle, setBillingCycle] = useState<'month' | 'quarter' | 'year'>('month');
|
||||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [subscribeMsg, setSubscribeMsg] = useState('');
|
||||
|
||||
// Modal states
|
||||
const [showModal, setShowModal] = useState<string | null>(null); // service type or null
|
||||
const [appCustomType, setAppCustomType] = useState<string>('skill'); // skill/agent/mcp
|
||||
const [platformForm, setPlatformForm] = useState({ companyName: '', contactName: '', usage: '' });
|
||||
const [dataRequestForm, setDataRequestForm] = useState({ title: '', description: '', requirements: '' });
|
||||
const [privateProjectForm, setPrivateProjectForm] = useState({ title: '', description: '', requirements: '' });
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
|
||||
const currentPlanLevel = user?.membership_level ?? 0;
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [plansRes, servicesRes, couponsRes] = await Promise.all([
|
||||
paymentAPI.getPlans(),
|
||||
paymentAPI.getServices(),
|
||||
paymentAPI.getCoupons(),
|
||||
]);
|
||||
if (plansRes.code === 0) setPlans(plansRes.data);
|
||||
if (servicesRes.code === 0) setServices(servicesRes.data);
|
||||
if (couponsRes.code === 0) setCoupons(couponsRes.data);
|
||||
} catch (e) {
|
||||
console.error('加载付费数据失败', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const getPrice = (plan: any, cycle: typeof billingCycle) => {
|
||||
const base = Number(plan.price) || 0;
|
||||
switch (cycle) {
|
||||
case 'quarter': return Math.round(base * 2.7);
|
||||
case 'year': return Math.round(base * 10);
|
||||
default: return base;
|
||||
}
|
||||
};
|
||||
|
||||
const getCycleLabel = (cycle: typeof billingCycle) => {
|
||||
switch (cycle) { case 'month': return '/月'; case 'quarter': return '/季'; case 'year': return '/年'; }
|
||||
};
|
||||
|
||||
const handleSubscribe = async (plan: any) => {
|
||||
try {
|
||||
setSubscribeMsg('');
|
||||
const res = await paymentAPI.createOrder({
|
||||
item_id: plan.id,
|
||||
order_type: 'membership',
|
||||
});
|
||||
if (res.code === 0) {
|
||||
const payRes = await paymentAPI.payOrder(res.data.id);
|
||||
if (payRes.code === 0) {
|
||||
setSubscribeMsg('成功订阅 ' + plan.name + '!');
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSubscribeMsg('订阅失败:' + (e.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
// 应用定制服务 - 提交
|
||||
const handleAppCustomSubmit = async (serviceId: string) => {
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const res = await paymentAPI.createCustomOrder({
|
||||
service_id: serviceId,
|
||||
description: '应用定制服务 - ' + (appCustomType === 'skill' ? 'Skills' : appCustomType === 'agent' ? 'Agent' : 'MCP') + ' 类型',
|
||||
budget: 0,
|
||||
contact_info: '',
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setSubscribeMsg('应用定制服务已提交!即将跳转到定制对话窗口...');
|
||||
setShowModal(null);
|
||||
// 跳转到聊天页面,带定制服务ID
|
||||
setTimeout(() => {
|
||||
window.location.hash = '#/chat?service_id=' + res.data.id;
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 平台代理申请 - 提交
|
||||
const handlePlatformProxySubmit = async (serviceId: string) => {
|
||||
if (!platformForm.companyName.trim()) {
|
||||
setSubscribeMsg('请填写公司名称');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const res = await paymentAPI.createServiceRequest({
|
||||
service_id: serviceId,
|
||||
title: '平台代理申请 - ' + platformForm.companyName,
|
||||
description: '公司名称: ' + platformForm.companyName + '\n联系人: ' + platformForm.contactName + '\n用途说明: ' + platformForm.usage,
|
||||
requirements: {
|
||||
company_name: platformForm.companyName,
|
||||
contact_name: platformForm.contactName,
|
||||
usage: platformForm.usage,
|
||||
},
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setSubscribeMsg('平台代理申请已成功提交!工单号: ' + res.data.id);
|
||||
setShowModal(null);
|
||||
setPlatformForm({ companyName: '', contactName: '', usage: '' });
|
||||
await loadData();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据定制服务 - 提交
|
||||
const handleDataCustomSubmit = async (serviceId: string) => {
|
||||
if (!dataRequestForm.title.trim()) {
|
||||
setSubscribeMsg('请填写需求标题');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const res = await paymentAPI.createServiceRequest({
|
||||
service_id: serviceId,
|
||||
title: dataRequestForm.title,
|
||||
description: dataRequestForm.description,
|
||||
requirements: { detail: dataRequestForm.requirements },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setSubscribeMsg('数据定制需求已成功提交!工单号: ' + res.data.id);
|
||||
setShowModal(null);
|
||||
setDataRequestForm({ title: '', description: '', requirements: '' });
|
||||
await loadData();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 私有化项目服务 - 提交
|
||||
const handlePrivateProjectSubmit = async (serviceId: string) => {
|
||||
if (!privateProjectForm.title.trim()) {
|
||||
setSubscribeMsg('请填写咨询标题');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitLoading(true);
|
||||
const res = await paymentAPI.createServiceRequest({
|
||||
service_id: serviceId,
|
||||
title: privateProjectForm.title,
|
||||
description: privateProjectForm.description,
|
||||
requirements: { detail: privateProjectForm.requirements },
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setSubscribeMsg('私有化项目咨询已成功提交!工单号: ' + res.data.id);
|
||||
setShowModal(null);
|
||||
setPrivateProjectForm({ title: '', description: '', requirements: '' });
|
||||
await loadData();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceAction = (svc: ServiceItem) => {
|
||||
switch (svc.type) {
|
||||
case 'app_custom':
|
||||
return { label: '前往定制', action: () => setShowModal('app_custom_' + svc.id) };
|
||||
case 'platform_proxy':
|
||||
return { label: '立即申请', action: () => setShowModal('platform_proxy_' + svc.id) };
|
||||
case 'data_custom':
|
||||
return { label: '提交需求', action: () => setShowModal('data_custom_' + svc.id) };
|
||||
case 'private_project':
|
||||
return { label: '咨询方案', action: () => setShowModal('private_project_' + svc.id) };
|
||||
default:
|
||||
return { label: '了解更多', action: () => {} };
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="pricing-page"><p style={{textAlign:'center',marginTop:60}}>加载中...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="pricing-page">
|
||||
<div className="pricing-header">
|
||||
<h1 className="pricing-title">订阅付费中心</h1>
|
||||
<p className="pricing-subtitle">选择最适合你的方案,解锁 AI 无限可能</p>
|
||||
</div>
|
||||
|
||||
<div className="pricing-cycle-tabs">
|
||||
{(['month', 'quarter', 'year'] as const).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
className={`pricing-cycle-tab ${billingCycle === cycle ? 'active' : ''}`}
|
||||
onClick={() => setBillingCycle(cycle)}
|
||||
>
|
||||
{cycle === 'month' ? '月付' : cycle === 'quarter' ? '季付' : '年付'}
|
||||
{cycle === 'year' && <span className="pricing-savings-badge">84折</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subscribeMsg && <div className="pricing-subscribe-msg">{subscribeMsg}</div>}
|
||||
|
||||
<div className="pricing-plans-grid">
|
||||
{plans.map((plan: any, idx: number) => {
|
||||
const isCurrent = currentPlanLevel === plan.level;
|
||||
const price = getPrice(plan, billingCycle);
|
||||
const isPopular = idx === 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`pricing-plan-card ${isCurrent ? 'pricing-plan--current' : ''} ${isPopular ? 'pricing-plan--popular' : ''}`}
|
||||
>
|
||||
{isPopular && (
|
||||
<div className="pricing-popular-ribbon"><IconSparkles /><span>最受欢迎</span></div>
|
||||
)}
|
||||
{isCurrent && <div className="pricing-current-badge">当前方案</div>}
|
||||
<div className="pricing-plan-head">
|
||||
<span className="pricing-plan-name">{plan.name}</span>
|
||||
<div className="pricing-plan-price-row">
|
||||
<span className="pricing-plan-price">
|
||||
<span className="pricing-currency">$</span>{price === 0 ? '0' : formatYuan(price)}
|
||||
</span>
|
||||
<span className="pricing-plan-cycle">{getCycleLabel(billingCycle)}</span>
|
||||
</div>
|
||||
<p className="pricing-plan-tokens">日赠 {formatYuan(plan.daily_free_tokens)} Tokens</p>
|
||||
</div>
|
||||
<div className="pricing-plan-features">
|
||||
{(plan.features || []).map((feat: string, i: number) => (
|
||||
<div key={i} className="pricing-feature-item">
|
||||
<span className="pricing-feature-check"><IconCheck /></span>
|
||||
<span>{feat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className={`pricing-subscribe-btn ${isCurrent ? 'pricing-btn--current' : ''}`}
|
||||
onClick={() => !isCurrent && handleSubscribe(plan)}
|
||||
>
|
||||
{isCurrent ? '当前方案' : '立即订阅'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 增值服务 - 四大类 */}
|
||||
<div className="pricing-section">
|
||||
<div className="pricing-section-header">
|
||||
<IconService />
|
||||
<h2 className="pricing-section-title">增值服务</h2>
|
||||
</div>
|
||||
<div className="pricing-services-grid-v2">
|
||||
{services.map((svc) => {
|
||||
const config = SERVICE_TYPE_CONFIG[svc.type] || SERVICE_TYPE_CONFIG.app_custom;
|
||||
const actionInfo = getServiceAction(svc);
|
||||
return (
|
||||
<div key={svc.id} className="pricing-service-card-v2">
|
||||
<div className="pricing-service-card-v2-icon" style={{ background: config.bg, color: config.color }}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="pricing-service-card-v2-body">
|
||||
<span className="pricing-service-card-v2-tag" style={{ color: config.color, background: config.bg }}>
|
||||
{config.label}
|
||||
</span>
|
||||
<h4 className="pricing-service-card-v2-name">{svc.name}</h4>
|
||||
<p className="pricing-service-card-v2-desc">{svc.description}</p>
|
||||
{(svc.features && svc.features.length > 0) && (
|
||||
<div className="pricing-service-card-v2-features">
|
||||
{svc.features.map((feat: string, i: number) => (
|
||||
<span key={i} className="pricing-service-card-v2-feature-tag">{feat}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pricing-service-card-v2-action">
|
||||
{svc.price > 0 ? (
|
||||
<span className="pricing-service-card-v2-price">{formatYuan(svc.price)} 元起</span>
|
||||
) : (
|
||||
<span className="pricing-service-card-v2-price">免费咨询</span>
|
||||
)}
|
||||
<button
|
||||
className="pricing-service-card-v2-btn"
|
||||
onClick={actionInfo.action}
|
||||
>
|
||||
{actionInfo.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{services.length === 0 && <p className="pricing-empty">暂无增值服务</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 优惠券 */}
|
||||
<div className="pricing-section">
|
||||
<div className="pricing-section-header">
|
||||
<IconCoupon />
|
||||
<h2 className="pricing-section-title">优惠券</h2>
|
||||
</div>
|
||||
<div className="pricing-coupons-row">
|
||||
{coupons.map((cp) => {
|
||||
const isPercent = cp.type === 'percent';
|
||||
return (
|
||||
<div key={cp.id} className="pricing-coupon-card">
|
||||
<div className="pricing-coupon-left">
|
||||
<span className="pricing-coupon-value">{isPercent ? `${cp.value}%` : `$${cp.value}`}</span>
|
||||
<span className="pricing-coupon-type">{isPercent ? '折扣券' : '立减券'}</span>
|
||||
</div>
|
||||
<div className="pricing-coupon-right">
|
||||
<div className="pricing-coupon-code">{cp.code}</div>
|
||||
<div className="pricing-coupon-desc">满 {formatYuan(cp.min_amount || 0)} 元可用</div>
|
||||
<div className="pricing-coupon-valid"><IconClock /><span>有效期至 {cp.expire_at || '--'}</span></div>
|
||||
<span className="pricing-coupon-status">{cp.status === 'active' ? '可使用' : cp.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{coupons.length === 0 && <p className="pricing-empty">暂无可用优惠券</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="pricing-section">
|
||||
<div className="pricing-section-header">
|
||||
<IconTag />
|
||||
<h2 className="pricing-section-title">支付方式</h2>
|
||||
</div>
|
||||
<div className="pricing-payment-row">
|
||||
<div className="pricing-payment-method"><IconWechat /><span>微信支付</span></div>
|
||||
<div className="pricing-payment-method"><IconAlipay /><span>支付宝</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Modal - 应用定制服务 ===== */}
|
||||
{showModal && showModal.startsWith('app_custom_') && (
|
||||
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
|
||||
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="pricing-modal-title">应用定制服务</h3>
|
||||
<p className="pricing-modal-subtitle">请选择您需要的定制类型</p>
|
||||
<div className="pricing-modal-app-type-grid">
|
||||
{[
|
||||
{ type: 'skill', label: 'Skills', desc: '自定义技能开发,扩展AI能力边界' },
|
||||
{ type: 'agent', label: 'Agent', desc: '智能体定制,构建专属AI助手' },
|
||||
{ type: 'mcp', label: 'MCP', desc: 'MCP协议开发,连接外部数据源' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className={`pricing-modal-app-type-card ${appCustomType === item.type ? 'active' : ''}`}
|
||||
onClick={() => setAppCustomType(item.type)}
|
||||
>
|
||||
<div className="pricing-modal-app-type-label">{item.label}</div>
|
||||
<div className="pricing-modal-app-type-desc">{item.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pricing-modal-actions">
|
||||
<button
|
||||
className="pricing-modal-btn-cancel"
|
||||
onClick={() => setShowModal(null)}
|
||||
>取消</button>
|
||||
<button
|
||||
className="pricing-modal-btn-confirm"
|
||||
disabled={submitLoading}
|
||||
onClick={() => handleAppCustomSubmit(showModal.replace('app_custom_', ''))}
|
||||
>
|
||||
{submitLoading ? '提交中...' : '确认并前往定制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Modal - 平台代理申请 ===== */}
|
||||
{showModal && showModal.startsWith('platform_proxy_') && (
|
||||
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
|
||||
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="pricing-modal-title">平台代理申请</h3>
|
||||
<p className="pricing-modal-subtitle">填写以下信息,提交代理申请</p>
|
||||
<div className="pricing-modal-form">
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>公司名称 <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入公司名称"
|
||||
value={platformForm.companyName}
|
||||
onChange={e => setPlatformForm({ ...platformForm, companyName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>联系人</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入联系人姓名"
|
||||
value={platformForm.contactName}
|
||||
onChange={e => setPlatformForm({ ...platformForm, contactName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>用途说明</label>
|
||||
<textarea
|
||||
placeholder="请简要说明代理用途"
|
||||
rows={3}
|
||||
value={platformForm.usage}
|
||||
onChange={e => setPlatformForm({ ...platformForm, usage: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricing-modal-actions">
|
||||
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
|
||||
<button
|
||||
className="pricing-modal-btn-confirm"
|
||||
disabled={submitLoading}
|
||||
onClick={() => handlePlatformProxySubmit(showModal.replace('platform_proxy_', ''))}
|
||||
>{submitLoading ? '提交中...' : '提交申请'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Modal - 数据定制服务 ===== */}
|
||||
{showModal && showModal.startsWith('data_custom_') && (
|
||||
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
|
||||
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="pricing-modal-title">数据定制服务</h3>
|
||||
<p className="pricing-modal-subtitle">请描述您的数据定制需求</p>
|
||||
<div className="pricing-modal-form">
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>需求标题 <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入需求标题"
|
||||
value={dataRequestForm.title}
|
||||
onChange={e => setDataRequestForm({ ...dataRequestForm, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>需求描述</label>
|
||||
<textarea
|
||||
placeholder="请描述您的数据定制需求详情"
|
||||
rows={3}
|
||||
value={dataRequestForm.description}
|
||||
onChange={e => setDataRequestForm({ ...dataRequestForm, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>具体要求</label>
|
||||
<textarea
|
||||
placeholder="请说明数据格式、数量、交付时间等具体要求"
|
||||
rows={2}
|
||||
value={dataRequestForm.requirements}
|
||||
onChange={e => setDataRequestForm({ ...dataRequestForm, requirements: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricing-modal-actions">
|
||||
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
|
||||
<button
|
||||
className="pricing-modal-btn-confirm"
|
||||
disabled={submitLoading}
|
||||
onClick={() => handleDataCustomSubmit(showModal.replace('data_custom_', ''))}
|
||||
>{submitLoading ? '提交中...' : '提交需求'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Modal - 私有化项目服务 ===== */}
|
||||
{showModal && showModal.startsWith('private_project_') && (
|
||||
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
|
||||
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="pricing-modal-title">私有化项目服务</h3>
|
||||
<p className="pricing-modal-subtitle">请描述您的私有化部署需求,我们将为您提供专业方案</p>
|
||||
<div className="pricing-modal-form">
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>项目标题 <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入项目标题"
|
||||
value={privateProjectForm.title}
|
||||
onChange={e => setPrivateProjectForm({ ...privateProjectForm, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>项目描述</label>
|
||||
<textarea
|
||||
placeholder="请描述您的私有化部署需求"
|
||||
rows={3}
|
||||
value={privateProjectForm.description}
|
||||
onChange={e => setPrivateProjectForm({ ...privateProjectForm, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="pricing-modal-form-group">
|
||||
<label>具体要求</label>
|
||||
<textarea
|
||||
placeholder="请说明部署规模、安全要求、时间计划等"
|
||||
rows={2}
|
||||
value={privateProjectForm.requirements}
|
||||
onChange={e => setPrivateProjectForm({ ...privateProjectForm, requirements: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricing-modal-actions">
|
||||
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
|
||||
<button
|
||||
className="pricing-modal-btn-confirm"
|
||||
disabled={submitLoading}
|
||||
onClick={() => handlePrivateProjectSubmit(showModal.replace('private_project_', ''))}
|
||||
>{submitLoading ? '提交中...' : '提交咨询'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,920 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { profileAPI } from '@/api';
|
||||
import { mockOrders, mockMembershipPlans, mockUserCoupons, mockApps, mockOperationLogs } from '@/mock/data';
|
||||
import type { Order, UserCoupon, MarketApp, OperationLog } from '@/types';
|
||||
import './ProfilePage.css';
|
||||
|
||||
// ===== Mock API Keys =====
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
prefix: string;
|
||||
fullKey: string;
|
||||
createdAt: string;
|
||||
status: 'active' | 'disabled';
|
||||
}
|
||||
|
||||
const mockApiKeys: ApiKey[] = [
|
||||
{ id: 'ak1', prefix: 'sk-aiflow-****a1b2', fullKey: 'sk-aiflow-u1-x8j3a1b2c3d4e5f6g7h8i9j0k1l2', createdAt: '2026-03-15T08:00:00Z', status: 'active' },
|
||||
{ id: 'ak2', prefix: 'sk-aiflow-****c3d4', fullKey: 'sk-aiflow-u1-y9k4c3d4e5f6g7h8i9j0k1l2m3n4', createdAt: '2026-04-20T10:00:00Z', status: 'active' },
|
||||
{ id: 'ak3', prefix: 'sk-aiflow-****e5f6', fullKey: 'sk-aiflow-u1-z0l5e5f6g7h8i9j0k1l2m3n4o5p6', createdAt: '2026-05-10T14:00:00Z', status: 'disabled' },
|
||||
];
|
||||
|
||||
// ===== SVG Icons =====
|
||||
const SvgIcons = {
|
||||
// Password
|
||||
lock: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
),
|
||||
phone: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
mail: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
),
|
||||
log: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
),
|
||||
order: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="12" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
member: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
),
|
||||
coupon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 12V20H4V12"/><path d="M22 7H2v5h20V7z"/><circle cx="12" cy="7" r="2"/><circle cx="12" cy="17" r="2"/><line x1="12" y1="9" x2="12" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
apps: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/>
|
||||
</svg>
|
||||
),
|
||||
key: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</svg>
|
||||
),
|
||||
copy: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
),
|
||||
check: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
eye: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
),
|
||||
calendar: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
diamond: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" transform="rotate(45 12 12)"/>
|
||||
</svg>
|
||||
),
|
||||
close: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
checkCircle: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// ===== Tabs =====
|
||||
type ProfileTab = 'security' | 'orders' | 'membership' | 'coupons' | 'apps' | 'apikeys';
|
||||
|
||||
interface TabDef {
|
||||
key: ProfileTab;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
const tabs: TabDef[] = [
|
||||
{ key: 'security', label: '账号安全', icon: SvgIcons.lock },
|
||||
{ key: 'orders', label: '我的订单', icon: SvgIcons.order },
|
||||
{ key: 'membership', label: '我的会员', icon: SvgIcons.member },
|
||||
{ key: 'coupons', label: '我的优惠券', icon: SvgIcons.coupon },
|
||||
{ key: 'apps', label: '我的已购应用', icon: SvgIcons.apps },
|
||||
{ key: 'apikeys', label: 'API密钥管理', icon: SvgIcons.key },
|
||||
];
|
||||
|
||||
// ===== Helper =====
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
// ===== ProfilePage =====
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('security');
|
||||
|
||||
// Security form states
|
||||
const [passwordForm, setPasswordForm] = useState({ old: '', newPass: '', confirm: '' });
|
||||
const [bindForm, setBindForm] = useState({ type: 'phone' as 'phone' | 'email', value: '' });
|
||||
|
||||
// Order filter
|
||||
const [orderFilter, setOrderFilter] = useState<Order['status'] | 'all'>('all');
|
||||
|
||||
// Coupon filter
|
||||
const [couponFilter, setCouponFilter] = useState<UserCoupon['status'] | 'all'>('all');
|
||||
|
||||
// API Key
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
|
||||
const [showKeyId, setShowKeyId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const memberPlan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
|
||||
|
||||
// User's purchased apps (via mockOrders -> app type orders)
|
||||
const purchasedAppOrders = useMemo(() => mockOrders.filter(o => o.userId === 'u1' && o.orderType === 'app' && o.status === 'paid'), []);
|
||||
const purchasedApps: MarketApp[] = useMemo(() => {
|
||||
return purchasedAppOrders
|
||||
.map(o => mockApps.find(a => a.name === o.itemName))
|
||||
.filter((a): a is MarketApp => !!a);
|
||||
}, [purchasedAppOrders]);
|
||||
|
||||
// Login logs (recent 5)
|
||||
const loginLogs = useMemo(() =>
|
||||
mockOperationLogs.filter(l => l.userId === 'u1').slice(0, 5),
|
||||
[]);
|
||||
|
||||
// Filter orders
|
||||
const filteredOrders = useMemo(() => {
|
||||
const userOrders = mockOrders.filter(o => o.userId === 'u1');
|
||||
return orderFilter === 'all' ? userOrders : userOrders.filter(o => o.status === orderFilter);
|
||||
}, [orderFilter]);
|
||||
|
||||
// Filter coupons
|
||||
const filteredCoupons = useMemo(() => {
|
||||
return couponFilter === 'all' ? mockUserCoupons : mockUserCoupons.filter(c => c.status === couponFilter);
|
||||
}, [couponFilter]);
|
||||
|
||||
const handleCopyKey = (key: ApiKey) => {
|
||||
navigator.clipboard.writeText(key.fullKey);
|
||||
setCopiedId(key.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleGenerateKey = () => {
|
||||
const newKey: ApiKey = {
|
||||
id: `ak${Date.now()}`,
|
||||
prefix: `sk-aiflow-****${Math.random().toString(36).slice(2, 6)}`,
|
||||
fullKey: `sk-aiflow-u1-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'active',
|
||||
};
|
||||
setApiKeys(prev => [newKey, ...prev]);
|
||||
};
|
||||
|
||||
const handleCopyPrefix = (key: ApiKey) => {
|
||||
navigator.clipboard.writeText(key.prefix);
|
||||
setCopiedId(key.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleToggleKeyStatus = (keyId: string) => {
|
||||
setApiKeys(prev => prev.map(k =>
|
||||
k.id === keyId ? { ...k, status: k.status === 'active' ? 'disabled' as const : 'active' as const } : k
|
||||
));
|
||||
};
|
||||
|
||||
const orderStatusLabels: Record<Order['status'] | 'all', string> = {
|
||||
all: '全部',
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
refunded: '已退款',
|
||||
};
|
||||
|
||||
const orderStatusColors: Record<Order['status'], string> = {
|
||||
pending: 'var(--color-warning)',
|
||||
paid: 'var(--color-success)',
|
||||
refunded: 'var(--color-text-tertiary)',
|
||||
};
|
||||
|
||||
const orderTypeLabels: Record<Order['orderType'], string> = {
|
||||
membership: '会员',
|
||||
app: '应用',
|
||||
service: '服务',
|
||||
};
|
||||
|
||||
const couponStatusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }> = {
|
||||
unused: { label: '未使用', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
||||
used: { label: '已使用', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
|
||||
expired: { label: '已过期', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
|
||||
};
|
||||
|
||||
const getCouponDescription = (coupon: UserCoupon['coupon']) => {
|
||||
if (coupon.type === 'fixed') return `满${coupon.minAmount}减${coupon.value}`;
|
||||
return `${coupon.value}折券,满${coupon.minAmount}可用`;
|
||||
};
|
||||
|
||||
const getLevelGradient = (level: number) => {
|
||||
const gradients: Record<number, string> = {
|
||||
0: 'linear-gradient(135deg, #8B919E, #BFC4CE)',
|
||||
2: 'linear-gradient(135deg, #5B5FED, #8B8DFF)',
|
||||
3: 'linear-gradient(135deg, #F59E0B, #FBBF24)',
|
||||
};
|
||||
return gradients[level] || gradients[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
{/* Header */}
|
||||
<div className="profile-header">
|
||||
<div className="profile-user-card">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
style={{ background: getLevelGradient(user.membership?.level ?? 0) }}
|
||||
>
|
||||
<span>{user.username.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="profile-user-info">
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">{user.username}</h1>
|
||||
{user.membership && (
|
||||
<span
|
||||
className="profile-level-badge"
|
||||
style={{ background: getLevelGradient(user.membership.level) }}
|
||||
>
|
||||
{user.membership.planName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user.membership && (
|
||||
<p className="profile-expire">
|
||||
<span className="profile-expire-icon">{SvgIcons.calendar}</span>
|
||||
会员到期:{formatShortDate(user.membership.expireAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="profile-body">
|
||||
{/* Left Tabs */}
|
||||
<nav className="profile-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`profile-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<span className="profile-tab-icon">{tab.icon}</span>
|
||||
<span className="profile-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right Content */}
|
||||
<div className="profile-content">
|
||||
{activeTab === 'security' && (
|
||||
<SecurityContent
|
||||
passwordForm={passwordForm}
|
||||
setPasswordForm={setPasswordForm}
|
||||
bindForm={bindForm}
|
||||
setBindForm={setBindForm}
|
||||
loginLogs={loginLogs}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'orders' && (
|
||||
<OrdersContent
|
||||
orders={filteredOrders}
|
||||
filter={orderFilter}
|
||||
setFilter={setOrderFilter}
|
||||
statusLabels={orderStatusLabels}
|
||||
statusColors={orderStatusColors}
|
||||
typeLabels={orderTypeLabels}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'membership' && (
|
||||
<MembershipContent user={user} memberPlan={memberPlan} />
|
||||
)}
|
||||
{activeTab === 'coupons' && (
|
||||
<CouponsContent
|
||||
coupons={filteredCoupons}
|
||||
filter={couponFilter}
|
||||
setFilter={setCouponFilter}
|
||||
getDescription={getCouponDescription}
|
||||
statusConfig={couponStatusConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'apps' && (
|
||||
<AppsContent apps={purchasedApps} purchasedOrders={purchasedAppOrders} />
|
||||
)}
|
||||
{activeTab === 'apikeys' && (
|
||||
<ApiKeysContent
|
||||
apiKeys={apiKeys}
|
||||
showKeyId={showKeyId}
|
||||
setShowKeyId={setShowKeyId}
|
||||
copiedId={copiedId}
|
||||
onCopy={handleCopyKey}
|
||||
onCopyPrefix={handleCopyPrefix}
|
||||
onGenerate={handleGenerateKey}
|
||||
onToggleStatus={handleToggleKeyStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Security Content =====
|
||||
interface SecurityContentProps {
|
||||
passwordForm: { old: string; newPass: string; confirm: string };
|
||||
setPasswordForm: React.Dispatch<React.SetStateAction<{ old: string; newPass: string; confirm: string }>>;
|
||||
bindForm: { type: 'phone' | 'email'; value: string };
|
||||
setBindForm: React.Dispatch<React.SetStateAction<{ type: 'phone' | 'email'; value: string }>>;
|
||||
loginLogs: OperationLog[];
|
||||
}
|
||||
|
||||
const SecurityContent: React.FC<SecurityContentProps> = ({ passwordForm, setPasswordForm, bindForm, setBindForm, loginLogs }) => {
|
||||
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
setPwdMsg(null);
|
||||
if (!passwordForm.old.trim()) {
|
||||
setPwdMsg({ type: 'error', text: '请输入旧密码' });
|
||||
return;
|
||||
}
|
||||
if (!passwordForm.newPass.trim() || passwordForm.newPass.length < 6) {
|
||||
setPwdMsg({ type: 'error', text: '新密码长度至少为6位' });
|
||||
return;
|
||||
}
|
||||
if (passwordForm.newPass !== passwordForm.confirm) {
|
||||
setPwdMsg({ type: 'error', text: '两次输入的新密码不一致' });
|
||||
return;
|
||||
}
|
||||
setPwdLoading(true);
|
||||
try {
|
||||
await profileAPI.updatePassword({
|
||||
old_password: passwordForm.old,
|
||||
new_password: passwordForm.newPass,
|
||||
});
|
||||
setPwdMsg({ type: 'success', text: '密码修改成功' });
|
||||
setPasswordForm({ old: '', newPass: '', confirm: '' });
|
||||
} catch (e: any) {
|
||||
setPwdMsg({ type: 'error', text: e.message || e.detail || '密码修改失败' });
|
||||
} finally {
|
||||
setPwdLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="security-content">
|
||||
{/* Change Password */}
|
||||
<section className="profile-section">
|
||||
<h3 className="section-title">
|
||||
<span className="section-icon">{SvgIcons.lock}</span>
|
||||
修改密码
|
||||
</h3>
|
||||
{pwdMsg && (
|
||||
<div className={`form-message ${pwdMsg.type}`}>
|
||||
{pwdMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<div className="form-item">
|
||||
<label className="form-label-small">旧密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="请输入旧密码"
|
||||
value={passwordForm.old}
|
||||
onChange={e => setPasswordForm(p => ({ ...p, old: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label-small">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
value={passwordForm.newPass}
|
||||
onChange={e => setPasswordForm(p => ({ ...p, newPass: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item">
|
||||
<label className="form-label-small">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="请再次输入新密码"
|
||||
value={passwordForm.confirm}
|
||||
onChange={e => setPasswordForm(p => ({ ...p, confirm: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item form-item-btn">
|
||||
<button className="btn-primary" onClick={handleChangePassword} disabled={pwdLoading}>
|
||||
{pwdLoading ? '修改中...' : '修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bind Phone/Email */}
|
||||
<section className="profile-section">
|
||||
<h3 className="section-title">
|
||||
<span className="section-icon">{SvgIcons.phone}</span>
|
||||
绑定手机 / 邮箱
|
||||
</h3>
|
||||
<div className="bind-container">
|
||||
<div className="bind-type-toggle">
|
||||
<button
|
||||
className={`bind-type-btn ${bindForm.type === 'phone' ? 'active' : ''}`}
|
||||
onClick={() => setBindForm(f => ({ ...f, type: 'phone' }))}
|
||||
>
|
||||
{SvgIcons.phone}
|
||||
<span>手机</span>
|
||||
</button>
|
||||
<button
|
||||
className={`bind-type-btn ${bindForm.type === 'email' ? 'active' : ''}`}
|
||||
onClick={() => setBindForm(f => ({ ...f, type: 'email' }))}
|
||||
>
|
||||
{SvgIcons.mail}
|
||||
<span>邮箱</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bind-input-row">
|
||||
<div className="form-item" style={{ flex: 1 }}>
|
||||
<label className="form-label-small">
|
||||
{bindForm.type === 'phone' ? '手机号' : '邮箱地址'}
|
||||
</label>
|
||||
<input
|
||||
type={bindForm.type === 'phone' ? 'tel' : 'email'}
|
||||
className="form-input"
|
||||
placeholder={bindForm.type === 'phone' ? '请输入手机号' : '请输入邮箱地址'}
|
||||
value={bindForm.value}
|
||||
onChange={e => setBindForm(f => ({ ...f, value: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-item form-item-btn">
|
||||
<label className="form-label-small"> </label>
|
||||
<button className="btn-secondary">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-item form-item-btn">
|
||||
<button className="btn-primary">确认绑定</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Login Logs */}
|
||||
<section className="profile-section">
|
||||
<h3 className="section-title">
|
||||
<span className="section-icon">{SvgIcons.log}</span>
|
||||
最近登录日志
|
||||
</h3>
|
||||
<div className="simple-table-wrapper">
|
||||
<table className="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>IP地址</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loginLogs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td>{log.action}</td>
|
||||
<td className="td-muted">{log.target}</td>
|
||||
<td className="td-mono">{log.ip}</td>
|
||||
<td className="td-muted">{formatDate(log.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Orders Content =====
|
||||
interface OrdersContentProps {
|
||||
orders: Order[];
|
||||
filter: Order['status'] | 'all';
|
||||
setFilter: React.Dispatch<React.SetStateAction<Order['status'] | 'all'>>;
|
||||
statusLabels: Record<Order['status'] | 'all', string>;
|
||||
statusColors: Record<Order['status'], string>;
|
||||
typeLabels: Record<Order['orderType'], string>;
|
||||
}
|
||||
|
||||
const OrdersContent: React.FC<OrdersContentProps> = ({ orders, filter, setFilter, statusLabels, statusColors, typeLabels }) => {
|
||||
return (
|
||||
<div className="tab-panel-content">
|
||||
{/* Filter Tabs */}
|
||||
<div className="filter-tabs">
|
||||
{(Object.keys(statusLabels) as (Order['status'] | 'all')[]).map(key => (
|
||||
<button
|
||||
key={key}
|
||||
className={`filter-tab ${filter === key ? 'active' : ''}`}
|
||||
onClick={() => setFilter(key)}
|
||||
>
|
||||
{statusLabels[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="simple-table-wrapper">
|
||||
<table className="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>类型</th>
|
||||
<th>商品名</th>
|
||||
<th>金额</th>
|
||||
<th>支付方式</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td className="td-mono">{order.id}</td>
|
||||
<td>
|
||||
<span className="order-type-tag">{typeLabels[order.orderType]}</span>
|
||||
</td>
|
||||
<td>{order.itemName}</td>
|
||||
<td className="td-price">¥{order.amount.toFixed(2)}</td>
|
||||
<td className="td-muted">{order.paymentMethod === 'wechat' ? '微信支付' : '支付宝'}</td>
|
||||
<td>
|
||||
<span className="status-dot" style={{ backgroundColor: statusColors[order.status] }} />
|
||||
{statusLabels[order.status]}
|
||||
</td>
|
||||
<td className="td-muted">{formatDate(order.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr><td colSpan={7} className="td-empty">暂无订单</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Membership Content =====
|
||||
interface MembershipContentProps {
|
||||
user: NonNullable<ReturnType<typeof useAuth>['user']>;
|
||||
memberPlan: typeof mockMembershipPlans[0] | undefined;
|
||||
}
|
||||
|
||||
const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan }) => {
|
||||
const [autoRenew, setAutoRenew] = useState(user.membership?.autoRenew ?? false);
|
||||
|
||||
// 使用 memberPlan 或从 user.membership 获取 level,判断是否付费会员
|
||||
const effectiveLevel = memberPlan?.level ?? user.membership?.level ?? 0;
|
||||
const effectiveName = memberPlan?.name || user.membership?.planName || '免费版';
|
||||
const hasPaidMembership = effectiveLevel > 0;
|
||||
|
||||
return (
|
||||
<div className="tab-panel-content">
|
||||
<div className="membership-card">
|
||||
<div className="membership-card-header">
|
||||
<div className="membership-card-icon">
|
||||
{SvgIcons.diamond}
|
||||
</div>
|
||||
<div className="membership-card-title">
|
||||
<h2>{effectiveName}</h2>
|
||||
{hasPaidMembership && (
|
||||
<span className="membership-level-tag">Lv.{effectiveLevel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPaidMembership && user.membership && (
|
||||
<>
|
||||
{memberPlan && (
|
||||
<div className="membership-stats">
|
||||
<div className="membership-stat">
|
||||
<span className="stat-label">每日可用Token</span>
|
||||
<span className="stat-value">{memberPlan.dailyFreeTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="membership-stat">
|
||||
<span className="stat-label">会员到期</span>
|
||||
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
|
||||
</div>
|
||||
<div className="membership-stat">
|
||||
<span className="stat-label">月费</span>
|
||||
<span className="stat-value">¥{memberPlan.price}/月</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!memberPlan && user.membership && (
|
||||
<div className="membership-stats">
|
||||
<div className="membership-stat">
|
||||
<span className="stat-label">会员到期</span>
|
||||
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memberPlan && (
|
||||
<div className="membership-features">
|
||||
<h4 className="features-title">会员权益</h4>
|
||||
<ul className="features-list">
|
||||
{memberPlan.features.map((f, i) => (
|
||||
<li key={i} className="feature-item">
|
||||
<span className="feature-check">{SvgIcons.checkCircle}</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="membership-auto-renew">
|
||||
<span className="auto-renew-label">自动续费</span>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRenew}
|
||||
onChange={e => setAutoRenew(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="membership-actions">
|
||||
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
|
||||
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasPaidMembership && (
|
||||
<div className="membership-empty">
|
||||
<p>您当前使用的是免费版,升级会员享受更多权益</p>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.hash = '#/pricing';
|
||||
}}
|
||||
>
|
||||
开通会员
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Coupons Content =====
|
||||
interface CouponsContentProps {
|
||||
coupons: UserCoupon[];
|
||||
filter: UserCoupon['status'] | 'all';
|
||||
setFilter: React.Dispatch<React.SetStateAction<UserCoupon['status'] | 'all'>>;
|
||||
getDescription: (coupon: UserCoupon['coupon']) => string;
|
||||
statusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }>;
|
||||
}
|
||||
|
||||
const CouponsContent: React.FC<CouponsContentProps> = ({ coupons, filter, setFilter, getDescription, statusConfig }) => {
|
||||
const filterLabels: Record<UserCoupon['status'] | 'all', string> = {
|
||||
all: '全部',
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
expired: '已过期',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-panel-content">
|
||||
<div className="filter-tabs">
|
||||
{(Object.keys(filterLabels) as (UserCoupon['status'] | 'all')[]).map(key => (
|
||||
<button
|
||||
key={key}
|
||||
className={`filter-tab ${filter === key ? 'active' : ''}`}
|
||||
onClick={() => setFilter(key)}
|
||||
>
|
||||
{filterLabels[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="coupon-list">
|
||||
{coupons.map(uc => {
|
||||
const cfg = statusConfig[uc.status];
|
||||
return (
|
||||
<div key={uc.id} className={`coupon-item ${uc.status !== 'unused' ? 'coupon-disabled' : ''}`}>
|
||||
<div className="coupon-left">
|
||||
<div className="coupon-code">{uc.coupon.code}</div>
|
||||
<div className="coupon-desc">{getDescription(uc.coupon)}</div>
|
||||
<div className="coupon-date">
|
||||
有效期:{formatShortDate(uc.coupon.validFrom)} ~ {formatShortDate(uc.coupon.validTo)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="coupon-right">
|
||||
<span
|
||||
className="coupon-status"
|
||||
style={{ color: cfg.color, backgroundColor: cfg.bg }}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
{uc.status === 'unused' && (
|
||||
<button className="btn-coupon-use">去使用</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{coupons.length === 0 && (
|
||||
<div className="content-empty">暂无优惠券</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Apps Content =====
|
||||
interface AppsContentProps {
|
||||
apps: MarketApp[];
|
||||
purchasedOrders: Order[];
|
||||
}
|
||||
|
||||
const AppsContent: React.FC<AppsContentProps> = ({ apps, purchasedOrders }) => {
|
||||
const appTypeLabels: Record<string, string> = {
|
||||
skill: '技能',
|
||||
agent: '智能体',
|
||||
mcp: 'MCP',
|
||||
};
|
||||
|
||||
const findOrderDate = (appName: string) => {
|
||||
const order = purchasedOrders.find(o => o.itemName === appName);
|
||||
return order ? formatDate(order.createdAt) : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-panel-content">
|
||||
<div className="apps-grid">
|
||||
{apps.map(app => (
|
||||
<div key={app.id} className="app-card">
|
||||
<div className="app-card-header">
|
||||
<div className="app-card-icon">
|
||||
<span>{app.icon || app.name.charAt(0)}</span>
|
||||
</div>
|
||||
<div className="app-card-info">
|
||||
<h4 className="app-card-name">{app.name}</h4>
|
||||
<div className="app-card-meta">
|
||||
<span className="app-type-tag">{appTypeLabels[app.type] || app.type}</span>
|
||||
<span className="app-source-tag">{app.source === 'official' ? '官方' : '用户'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="app-card-desc">{app.description}</p>
|
||||
<div className="app-card-footer">
|
||||
<span className="app-purchase-date">
|
||||
{SvgIcons.calendar}
|
||||
{findOrderDate(app.name)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{apps.length === 0 && (
|
||||
<div className="content-empty">暂未购买任何应用</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== API Keys Content =====
|
||||
interface ApiKeysContentProps {
|
||||
apiKeys: ApiKey[];
|
||||
showKeyId: string | null;
|
||||
setShowKeyId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
copiedId: string | null;
|
||||
onCopy: (key: ApiKey) => void;
|
||||
onCopyPrefix: (key: ApiKey) => void;
|
||||
onGenerate: () => void;
|
||||
onToggleStatus: (keyId: string) => void;
|
||||
}
|
||||
|
||||
const ApiKeysContent: React.FC<ApiKeysContentProps> = ({
|
||||
apiKeys, showKeyId, setShowKeyId, copiedId, onCopy, onCopyPrefix, onGenerate, onToggleStatus,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tab-panel-content">
|
||||
<div className="apikey-header">
|
||||
<div className="apikey-info">
|
||||
<h3>API密钥</h3>
|
||||
<p className="apikey-desc">使用API密钥访问AIFlow接口,请妥善保管您的密钥</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={onGenerate}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
生成新API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="apikey-list">
|
||||
<div className="apikey-list-header">
|
||||
<span>密钥前缀</span>
|
||||
<span>创建时间</span>
|
||||
<span>状态</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{apiKeys.map(key => (
|
||||
<div key={key.id} className="apikey-row">
|
||||
<div className="apikey-prefix-col">
|
||||
<code className="apikey-prefix">{key.prefix}</code>
|
||||
{showKeyId === key.id && (
|
||||
<div className="apikey-full-reveal">
|
||||
<code>{key.fullKey}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="apikey-date">{formatDate(key.createdAt)}</span>
|
||||
<span>
|
||||
<span className={`apikey-status ${key.status}`}>
|
||||
{key.status === 'active' ? '活跃' : '已禁用'}
|
||||
</span>
|
||||
</span>
|
||||
<div className="apikey-actions">
|
||||
<button
|
||||
className="apikey-action-btn"
|
||||
onClick={() => {
|
||||
if (showKeyId === key.id) {
|
||||
setShowKeyId(null);
|
||||
} else {
|
||||
setShowKeyId(key.id);
|
||||
onCopyPrefix(key);
|
||||
}
|
||||
}}
|
||||
title={showKeyId === key.id ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{SvgIcons.eye}
|
||||
</button>
|
||||
<button
|
||||
className="apikey-action-btn"
|
||||
onClick={() => {
|
||||
if (showKeyId === key.id || showKeyId === null) {
|
||||
onCopy(key);
|
||||
} else {
|
||||
onCopyPrefix(key);
|
||||
}
|
||||
}}
|
||||
title="复制密钥"
|
||||
>
|
||||
{copiedId === key.id ? SvgIcons.check : SvgIcons.copy}
|
||||
</button>
|
||||
<button
|
||||
className={`apikey-action-btn ${key.status === 'active' ? 'danger' : ''}`}
|
||||
onClick={() => onToggleStatus(key.id)}
|
||||
title={key.status === 'active' ? '禁用' : '启用'}
|
||||
>
|
||||
{key.status === 'active' ? '禁用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,480 @@
|
||||
/* ===== Token Dashboard Page ===== */
|
||||
.tkd-page {
|
||||
padding: var(--space-3xl) var(--space-4xl);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.tkd-header {
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.tkd-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tkd-subtitle {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Warning Banner ===== */
|
||||
.tkd-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--color-warning-light);
|
||||
border: 1px solid rgba(232, 146, 44, 0.2);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-2xl);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tkd-warning-banner svg {
|
||||
color: var(--color-warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tkd-warning-banner strong {
|
||||
color: var(--color-warning);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== KPI Cards ===== */
|
||||
.tkd-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.tkd-kpi-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.tkd-kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.tkd-kpi--blue {
|
||||
background: linear-gradient(135deg, #5B5FED 0%, #8B8DFF 100%);
|
||||
}
|
||||
|
||||
.tkd-kpi--green {
|
||||
background: linear-gradient(135deg, #219B5E 0%, #34C97A 100%);
|
||||
}
|
||||
|
||||
.tkd-kpi--orange {
|
||||
background: linear-gradient(135deg, #E8922C 0%, #F5A84C 100%);
|
||||
}
|
||||
|
||||
.tkd-kpi-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
}
|
||||
|
||||
.tkd-kpi-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
bottom: -20px;
|
||||
left: -20px;
|
||||
}
|
||||
|
||||
.tkd-kpi-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tkd-kpi-icon--blue { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
|
||||
.tkd-kpi-icon--green { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
|
||||
.tkd-kpi-icon--orange { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
|
||||
|
||||
.tkd-kpi-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tkd-kpi-label {
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tkd-kpi-value {
|
||||
font-size: clamp(1.25rem, 5vw, var(--text-3xl));
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.tkd-kpi-sub {
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Middle Row ===== */
|
||||
.tkd-middle-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 220px;
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
/* ===== Rank Card ===== */
|
||||
.tkd-rank-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.tkd-rank-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tkd-rank-header svg {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.tkd-rank-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tkd-rank-model-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tkd-rank-model-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tkd-rank-model-tokens {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Rank List */
|
||||
.tkd-rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tkd-rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tkd-rank-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tkd-rank-item-idx {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.tkd-rank-item:nth-child(1) .tkd-rank-item-idx { background: #FEF3C7; color: #D97706; }
|
||||
.tkd-rank-item:nth-child(2) .tkd-rank-item-idx { background: #E5E7EB; color: #6B7280; }
|
||||
.tkd-rank-item:nth-child(3) .tkd-rank-item-idx { background: #FDE8CD; color: #B45309; }
|
||||
|
||||
.tkd-rank-item-name {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tkd-rank-item-val {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tkd-rank-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-disabled);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* ===== Actions Card ===== */
|
||||
.tkd-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tkd-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.tkd-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tkd-btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.tkd-btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tkd-btn-secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-accent);
|
||||
border: 1.5px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.tkd-btn-secondary:hover {
|
||||
background: var(--color-accent-subtle);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
.tkd-table-wrapper {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.tkd-table-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
padding: var(--space-xl) var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.tkd-table-scroll {
|
||||
overflow-x: auto;
|
||||
padding: var(--space-lg) var(--space-xl) var(--space-sm);
|
||||
}
|
||||
|
||||
.tkd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tkd-table th {
|
||||
text-align: left;
|
||||
padding: var(--space-md) var(--space-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tkd-table td {
|
||||
padding: var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tkd-table tbody tr:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tkd-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tkd-col-num {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tkd-col-bold {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tkd-col-cost {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tkd-col-time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.tkd-model-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== Pagination ===== */
|
||||
.tkd-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.tkd-page-btn {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tkd-page-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tkd-page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tkd-page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===== Loading ===== */
|
||||
.tkd-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-5xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.tkd-loading .audit-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border-light);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.tkd-page {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
.tkd-kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tkd-middle-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tkd-kpi-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.tkd-kpi-value {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { tokenAPI } from '@/api';
|
||||
import type { TokenUsageLog } from '@/types';
|
||||
import './TokenDashboardPage.css';
|
||||
|
||||
// ---- SVG Icons ----
|
||||
const IconToken = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="12" r="5" stroke="currentColor" strokeWidth="1.4" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
||||
<line x1="12" y1="2" x2="12" y2="5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<line x1="12" y1="19" x2="12" y2="22" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconWallet = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<line x1="2" y1="9" x2="22" y2="9" stroke="currentColor" strokeWidth="1.4" />
|
||||
<circle cx="17" cy="12" r="1.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconApp = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconModel = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconTrophy = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 4h12v4a6 6 0 01-12 0V4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d="M6 4H4a2 2 0 00-2 2v2a4 4 0 004 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d="M18 4h2a2 2 0 012 2v2a4 4 0 01-4 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<line x1="12" y1="14" x2="12" y2="20" stroke="currentColor" strokeWidth="1.8" />
|
||||
<line x1="8" y1="20" x2="16" y2="20" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconWarning = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 22h20L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="18.5" r="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconDownload = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRecharge = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
|
||||
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconArrowUp = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="18 15 12 9 6 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---- Formatting helpers ----
|
||||
function formatNumber(n: number | undefined | null): string {
|
||||
if (n == null) return '0';
|
||||
if (n >= 1000000000) {
|
||||
return (n / 1000000000).toFixed(2).replace(/\.?0+$/, '') + 'B';
|
||||
}
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M';
|
||||
}
|
||||
if (n >= 10000) {
|
||||
return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
}
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
// ---- Data analysis ----
|
||||
function getTopModel(logs: TokenUsageLog[]): { name: string; total: number } {
|
||||
const map = new Map<string, number>();
|
||||
logs.forEach((l) => {
|
||||
map.set(l.modelName, (map.get(l.modelName) || 0) + l.totalTokens);
|
||||
});
|
||||
let max = 0;
|
||||
let name = '';
|
||||
map.forEach((v, k) => { if (v > max) { max = v; name = k; } });
|
||||
return { name, total: max };
|
||||
}
|
||||
|
||||
function getTopApps(logs: TokenUsageLog[]): { name: string; total: number }[] {
|
||||
const map = new Map<string, number>();
|
||||
logs.forEach((l) => {
|
||||
if (l.appName) {
|
||||
map.set(l.appName, (map.get(l.appName) || 0) + l.totalTokens);
|
||||
}
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([name, total]) => ({ name, total }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function getTodayAppCount(logs: TokenUsageLog[], serverTime?: string): number {
|
||||
// 优先使用服务器时间,否则使用本地时间
|
||||
const today = (serverTime || new Date().toISOString()).slice(0, 10);
|
||||
const apps = new Set<string>();
|
||||
logs.forEach((l) => {
|
||||
if (l.appName && l.createdAt && String(l.createdAt).startsWith(today)) {
|
||||
apps.add(l.appName);
|
||||
}
|
||||
});
|
||||
return apps.size;
|
||||
}
|
||||
|
||||
const TokenDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [logs, setLogs] = useState<TokenUsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [recharging, setRecharging] = useState(false);
|
||||
|
||||
// 从后端API获取服务器时间(用于时间同步)
|
||||
const [serverTime, setServerTime] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [overviewRes, balanceRes, logsRes] = await Promise.all([
|
||||
tokenAPI.getOverview(),
|
||||
tokenAPI.getBalance(),
|
||||
tokenAPI.getLogs(),
|
||||
]);
|
||||
setTotalConsumed(overviewRes.data?.total_used || overviewRes.data?.total_consumed || overviewRes.data?.totalConsumed || 0);
|
||||
setBalance(balanceRes.data?.balance || 0);
|
||||
// 从overview响应中获取服务器时间
|
||||
const serverTimeStr = overviewRes.data?.server_time || overviewRes.data?.serverTime || overviewRes.data?.current_time || overviewRes.data?.time || '';
|
||||
setServerTime(serverTimeStr);
|
||||
// 兼容分页格式和直接数组两种返回格式
|
||||
const rawLogs = (logsRes.data as any)?.items || logsRes.data || [];
|
||||
// 兼容后端返回的 snake_case 字段名
|
||||
setLogs(rawLogs.map((item: any) => ({
|
||||
...item,
|
||||
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
|
||||
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
|
||||
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
|
||||
modelName: item.modelName ?? item.model_name ?? '',
|
||||
cost: item.cost ?? 0,
|
||||
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
|
||||
})));
|
||||
} catch (err: any) {
|
||||
setError(err.message || '数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// 导出CSV功能
|
||||
const handleExportCSV = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
// 分页获取所有token日志(后端限制page_size <= 100)
|
||||
const allLogs: TokenUsageLog[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const res = await tokenAPI.getLogs({ page_size: 100, page });
|
||||
const rawLogs = (res.data as any)?.items || res.data || [];
|
||||
if (!Array.isArray(rawLogs) || rawLogs.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
allLogs.push(...rawLogs.map((item: any) => ({
|
||||
...item,
|
||||
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
|
||||
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
|
||||
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
|
||||
modelName: item.modelName ?? item.model_name ?? '',
|
||||
cost: item.cost ?? 0,
|
||||
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
|
||||
appName: item.appName ?? item.app_name ?? '',
|
||||
})));
|
||||
if (rawLogs.length < 100) hasMore = false;
|
||||
else page++;
|
||||
}
|
||||
|
||||
if (allLogs.length === 0) {
|
||||
alert('暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成CSV内容
|
||||
const headers = ['时间', '模型', '应用', 'Prompt Tokens', 'Completion Tokens', '总Tokens', '费用'];
|
||||
const csvRows = [headers.join(',')];
|
||||
allLogs.forEach(log => {
|
||||
const row = [
|
||||
formatTime(log.createdAt),
|
||||
`"${(log.modelName || '').replace(/"/g, '""')}"`,
|
||||
`"${(log.appName || '').replace(/"/g, '""')}"`,
|
||||
log.promptTokens || 0,
|
||||
log.completionTokens || 0,
|
||||
log.totalTokens || 0,
|
||||
(log.cost ?? 0).toFixed(4),
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
const csvContent = '\uFEFF' + csvRows.join('\n');
|
||||
|
||||
// 触发浏览器下载
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `token-usage-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
const msg = err?.detail || err?.message || (typeof err === 'string' ? err : JSON.stringify(err));
|
||||
alert('导出失败: ' + msg);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 一键充值功能 - 跳转到付费中心
|
||||
const handleRecharge = () => {
|
||||
navigate('/pricing');
|
||||
};
|
||||
|
||||
const topModel = getTopModel(logs);
|
||||
const topApps = getTopApps(logs);
|
||||
const todayAppCount = getTodayAppCount(logs, serverTime);
|
||||
const lowBalanceThreshold = 200000;
|
||||
const isLowBalance = balance < lowBalanceThreshold;
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 5;
|
||||
const totalPages = Math.ceil(logs.length / pageSize);
|
||||
const pagedLogs = logs.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tkd-page">
|
||||
<div className="tkd-header">
|
||||
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
|
||||
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
|
||||
</div>
|
||||
<div className="tkd-loading">
|
||||
<div className="audit-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="tkd-page">
|
||||
<div className="tkd-header">
|
||||
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
|
||||
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
|
||||
</div>
|
||||
<div className="tkd-warning-banner">
|
||||
<IconWarning />
|
||||
<span>数据加载失败:{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tkd-page">
|
||||
{/* Header */}
|
||||
<div className="tkd-header">
|
||||
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
|
||||
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Warning Banner */}
|
||||
{isLowBalance && (
|
||||
<div className="tkd-warning-banner">
|
||||
<IconWarning />
|
||||
<span>Token余额不足</span>
|
||||
<strong>{formatNumber(balance)}</strong>
|
||||
<span>,建议尽快充值以确保服务连续性</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="tkd-kpi-grid">
|
||||
<div className="tkd-kpi-card tkd-kpi--blue">
|
||||
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--blue">
|
||||
<IconToken />
|
||||
</div>
|
||||
<div className="tkd-kpi-content">
|
||||
<span className="tkd-kpi-label">累计总消耗 Token</span>
|
||||
<span className="tkd-kpi-value">{formatNumber(totalConsumed)}</span>
|
||||
<span className="tkd-kpi-sub">历史总计</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tkd-kpi-card tkd-kpi--green">
|
||||
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--green">
|
||||
<IconWallet />
|
||||
</div>
|
||||
<div className="tkd-kpi-content">
|
||||
<span className="tkd-kpi-label">当前余额</span>
|
||||
<span className="tkd-kpi-value">{formatNumber(balance)}</span>
|
||||
<span className="tkd-kpi-sub">
|
||||
{isLowBalance ? '余额偏低' : '余量充足'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tkd-kpi-card tkd-kpi--orange">
|
||||
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--orange">
|
||||
<IconApp />
|
||||
</div>
|
||||
<div className="tkd-kpi-content">
|
||||
<span className="tkd-kpi-label">当日使用中应用</span>
|
||||
<span className="tkd-kpi-value">{todayAppCount}</span>
|
||||
<span className="tkd-kpi-sub">个应用活跃</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rankings + Actions */}
|
||||
<div className="tkd-middle-row">
|
||||
{/* Top Model */}
|
||||
<div className="tkd-rank-card">
|
||||
<div className="tkd-rank-header">
|
||||
<h3 className="tkd-rank-title">消耗量第一的大模型</h3>
|
||||
<IconModel />
|
||||
</div>
|
||||
<div className="tkd-rank-model-body">
|
||||
<span className="tkd-rank-model-name">{topModel.name || '-'}</span>
|
||||
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Apps */}
|
||||
<div className="tkd-rank-card">
|
||||
<div className="tkd-rank-header">
|
||||
<h3 className="tkd-rank-title">消耗量 TOP3 应用</h3>
|
||||
<IconTrophy />
|
||||
</div>
|
||||
<div className="tkd-rank-list">
|
||||
{topApps.map((app, idx) => (
|
||||
<div key={app.name} className="tkd-rank-item">
|
||||
<span className="tkd-rank-item-idx">{idx + 1}</span>
|
||||
<span className="tkd-rank-item-name">{app.name}</span>
|
||||
<span className="tkd-rank-item-val">{formatNumber(app.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
{topApps.length === 0 && (
|
||||
<div className="tkd-rank-empty">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="tkd-actions-card">
|
||||
<button className="tkd-btn tkd-btn-primary" onClick={handleExportCSV} disabled={exporting}>
|
||||
<IconDownload />
|
||||
<span>{exporting ? '导出中...' : '导出数据'}</span>
|
||||
</button>
|
||||
<button className="tkd-btn tkd-btn-secondary" onClick={handleRecharge} disabled={recharging}>
|
||||
<IconRecharge />
|
||||
<span>{recharging ? '充值中...' : '一键充值'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Table */}
|
||||
<div className="tkd-table-wrapper">
|
||||
<h3 className="tkd-table-title">Token 消耗明细</h3>
|
||||
<div className="tkd-table-scroll">
|
||||
<table className="tkd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>模型</th>
|
||||
<th>应用</th>
|
||||
<th className="tkd-col-num">Prompt Tokens</th>
|
||||
<th className="tkd-col-num">Completion Tokens</th>
|
||||
<th className="tkd-col-num">总 Tokens</th>
|
||||
<th className="tkd-col-num">费用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="tkd-col-time">{formatTime(log.createdAt)}</td>
|
||||
<td>
|
||||
<span className="tkd-model-badge">{log.modelName}</span>
|
||||
</td>
|
||||
<td>{log.appName || '-'}</td>
|
||||
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
|
||||
<td className="tkd-col-num">{formatNumber(log.completionTokens)}</td>
|
||||
<td className="tkd-col-num tkd-col-bold">{formatNumber(log.totalTokens)}</td>
|
||||
<td className="tkd-col-num tkd-col-cost">${(log.cost ?? 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="tkd-pagination">
|
||||
<button
|
||||
className="tkd-page-btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="tkd-page-info">{page} / {totalPages}</span>
|
||||
<button
|
||||
className="tkd-page-btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTime(iso: string | number | undefined | null): string {
|
||||
if (iso == null) return '-';
|
||||
let d: Date;
|
||||
// Handle Unix timestamp (seconds or milliseconds) or ISO string
|
||||
if (typeof iso === 'number') {
|
||||
d = new Date(iso < 10000000000 ? iso * 1000 : iso);
|
||||
} else if (/^\d+$/.test(iso)) {
|
||||
const ts = parseInt(iso, 10);
|
||||
d = new Date(ts < 10000000000 ? ts * 1000 : ts);
|
||||
} else {
|
||||
d = new Date(iso);
|
||||
}
|
||||
// Check validity
|
||||
if (isNaN(d.getTime())) return String(iso).slice(0, 19) || '-';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default TokenDashboardPage;
|
||||
Reference in new issue
Block a user