1122 lines
26 KiB
Vue
1122 lines
26 KiB
Vue
<template>
|
||
<div class="token-market-page">
|
||
<!-- 左侧筛选栏 -->
|
||
<aside class="menu-sidebar">
|
||
<div class="sidebar-header">
|
||
<h3 class="sidebar-title">
|
||
<i class="el-icon-s-operation"></i>
|
||
<span>筛选模型</span>
|
||
</h3>
|
||
<button
|
||
v-if="hasActiveFilter"
|
||
class="reset-btn"
|
||
@click="resetFilter"
|
||
>
|
||
<i class="el-icon-refresh-right"></i>
|
||
重置
|
||
</button>
|
||
</div>
|
||
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
class="product-search"
|
||
clearable
|
||
prefix-icon="el-icon-search"
|
||
placeholder="输入关键词 搜索模型"
|
||
></el-input>
|
||
|
||
<div class="filter-scroll">
|
||
<section class="token-filter-section">
|
||
<header class="filter-header">
|
||
<h4>模型类型</h4>
|
||
</header>
|
||
<div class="token-filter-grid">
|
||
<button
|
||
class="filter-chip"
|
||
:class="{ active: !tokenActiveModelType }"
|
||
@click="tokenActiveModelType = ''"
|
||
>
|
||
全部
|
||
</button>
|
||
<button
|
||
v-for="item in tokenModelTypeList"
|
||
:key="item"
|
||
class="filter-chip"
|
||
:class="{ active: tokenActiveModelType === item }"
|
||
@click="toggleTokenModelType(item)"
|
||
>
|
||
{{ item }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="token-filter-section">
|
||
<header class="filter-header">
|
||
<h4>提供方</h4>
|
||
</header>
|
||
<div class="token-filter-grid">
|
||
<button
|
||
class="filter-chip"
|
||
:class="{ active: !tokenActiveProvider }"
|
||
@click="tokenActiveProvider = ''"
|
||
>
|
||
全部
|
||
</button>
|
||
<button
|
||
v-for="item in tokenProviderList"
|
||
:key="item"
|
||
class="filter-chip"
|
||
:class="{ active: tokenActiveProvider === item }"
|
||
@click="toggleTokenProvider(item)"
|
||
>
|
||
<el-tooltip
|
||
:content="item"
|
||
placement="top"
|
||
:disabled="!isProviderNameOverflow(item)"
|
||
>
|
||
<span class="chip-label">{{ item }}</span>
|
||
</el-tooltip>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 主内容区 -->
|
||
<main class="main-content">
|
||
|
||
<div class="token-market-grid">
|
||
<article
|
||
v-for="product in displayedTokenProducts"
|
||
:key="product.id"
|
||
class="token-market-card"
|
||
>
|
||
<div class="token-card-top">
|
||
<span class="token-provider-avatar">
|
||
<img
|
||
v-if="getModelLogoUrl(product.model_logo) && !product.logoLoadFailed"
|
||
:src="getModelLogoUrl(product.model_logo)"
|
||
:alt="product.display_name || product.model_name || '模型logo'"
|
||
@error="handleModelLogoError(product)"
|
||
>
|
||
<template v-else>{{ getProviderInitial(product.provider || product.display_name) }}</template>
|
||
</span>
|
||
<div class="token-title-group">
|
||
<h3>{{ product.display_name || product.model_name }}</h3>
|
||
<p>
|
||
<i class="el-icon-office-building"></i>
|
||
{{ product.provider || product.model_name || '-' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="token-price-grid">
|
||
<div
|
||
v-for="(price, priceIndex) in parseBillingMethod(product.billing_method)"
|
||
:key="`${product.id}-billing-${priceIndex}`"
|
||
class="token-price-item"
|
||
>
|
||
<span class="price-name">{{ price.name }}</span>
|
||
<strong class="price-value">{{ price.price }}</strong>
|
||
<em class="price-desc">{{ price.desc }}</em>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="token-card-footer">
|
||
<div class="token-feature-tags">
|
||
<span
|
||
v-for="(tag, tagIndex) in getTokenFeatureTags(product)"
|
||
:key="`${tag.type}-${tag.text}-${tagIndex}`"
|
||
:class="tag.type"
|
||
>
|
||
<i :class="tag.icon"></i>
|
||
{{ tag.text }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="token-hover-actions">
|
||
<button
|
||
class="primary-action"
|
||
:class="{ disabled: !isModelExperienceEnabled(product) }"
|
||
:disabled="!isModelExperienceEnabled(product)"
|
||
@click.stop="goModelExperience(product)"
|
||
>
|
||
<i class="el-icon-video-play"></i>
|
||
快速体验
|
||
</button>
|
||
<button @click.stop="goModelApiDocument(product)">
|
||
<i class="el-icon-document"></i>
|
||
API参考
|
||
</button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-if="!displayedTokenProducts.length" class="empty-state">
|
||
<div class="empty-icon">
|
||
<i class="el-icon-box"></i>
|
||
</div>
|
||
<p class="empty-text">{{ searchKeyword ? '没有匹配的模型' : '暂无模型数据' }}</p>
|
||
<span class="empty-hint">
|
||
{{ hasActiveFilter ? '可以调整搜索关键词或筛选条件后再试' : '请稍后再来,或联系管理员补充模型' }}
|
||
</span>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { reqNavList } from "@/api/newHome";
|
||
|
||
const getImageUrlPrefix = () => {
|
||
const origin = window.location.origin
|
||
if (origin.includes('localhost') || origin.includes('dev.opencomputing.cn')) {
|
||
return 'https://dev.opencomputing.cn/idfile?path='
|
||
}
|
||
if (origin.includes('www.opencomputing.cn')) {
|
||
return 'https://www.opencomputing.cn/idfile?path='
|
||
}
|
||
if (origin.includes('www.ncmatch.cn')) {
|
||
return 'https://www.ncmatch.cn/idfile?path='
|
||
}
|
||
return `${origin}/idfile?path=`
|
||
}
|
||
|
||
export default {
|
||
name: "TokenMarket",
|
||
data() {
|
||
return {
|
||
searchKeyword: '',
|
||
tokenList: [],
|
||
tokenModelTypeList: [],
|
||
tokenProviderList: [],
|
||
tokenActiveModelType: '',
|
||
tokenActiveProvider: "",
|
||
IMG_URL: getImageUrlPrefix(),
|
||
};
|
||
},
|
||
computed: {
|
||
displayedTokenProducts() {
|
||
const keyword = this.searchKeyword.trim().toLowerCase();
|
||
return this.tokenList.filter(item => {
|
||
const matchKeyword = !keyword || [
|
||
item.display_name,
|
||
item.model_name,
|
||
item.model_type,
|
||
item.provider,
|
||
item.description
|
||
].join(' ').toLowerCase().includes(keyword);
|
||
const matchType = !this.tokenActiveModelType || item.model_type === this.tokenActiveModelType;
|
||
const matchProvider = !this.tokenActiveProvider || item.provider === this.tokenActiveProvider;
|
||
return matchKeyword && matchType && matchProvider;
|
||
});
|
||
},
|
||
hasActiveFilter() {
|
||
return Boolean(
|
||
this.searchKeyword.trim() ||
|
||
this.tokenActiveModelType ||
|
||
this.tokenActiveProvider
|
||
);
|
||
},
|
||
typeCountMap() {
|
||
const map = {};
|
||
this.tokenList.forEach(item => {
|
||
if (item.model_type) {
|
||
map[item.model_type] = (map[item.model_type] || 0) + 1;
|
||
}
|
||
});
|
||
return map;
|
||
},
|
||
providerCountMap() {
|
||
const map = {};
|
||
this.tokenList.forEach(item => {
|
||
if (item.provider) {
|
||
map[item.provider] = (map[item.provider] || 0) + 1;
|
||
}
|
||
});
|
||
return map;
|
||
},
|
||
loginState() {
|
||
const userId = sessionStorage.getItem('userId');
|
||
return userId !== null && userId !== 'null' && userId !== '';
|
||
},
|
||
},
|
||
async mounted() {
|
||
await this.loadNavData();
|
||
},
|
||
methods: {
|
||
isTokenMarketCategory(title) {
|
||
return ['TOKEN市集', 'Token市集', 'token市集', 'Token Market'].includes(title);
|
||
},
|
||
|
||
setTokenMarketData(category) {
|
||
const marketData = category && category.token_market && category.token_market.data
|
||
? category.token_market.data
|
||
: {};
|
||
this.tokenList = Array.isArray(marketData.model_list) ? marketData.model_list : [];
|
||
this.tokenModelTypeList = Array.isArray(marketData.model_type_list) ? marketData.model_type_list : [];
|
||
this.tokenProviderList = Array.isArray(marketData.provider_list) ? marketData.provider_list : [];
|
||
},
|
||
|
||
toggleTokenModelType(type) {
|
||
this.tokenActiveModelType = this.tokenActiveModelType === type ? '' : type;
|
||
},
|
||
|
||
toggleTokenProvider(provider) {
|
||
this.tokenActiveProvider = this.tokenActiveProvider === provider ? '' : provider;
|
||
},
|
||
|
||
resetFilter() {
|
||
this.searchKeyword = '';
|
||
this.tokenActiveModelType = '';
|
||
this.tokenActiveProvider = '';
|
||
},
|
||
|
||
getTypeCount(type) {
|
||
return this.typeCountMap[type] || 0;
|
||
},
|
||
|
||
getProviderCount(provider) {
|
||
return this.providerCountMap[provider] || 0;
|
||
},
|
||
|
||
// 提供方名称长度判定:> 8 个字符时大概率会换行超过 2 行,启用 tooltip
|
||
isProviderNameOverflow(name) {
|
||
if (!name) return false;
|
||
// 中文字符按 2 个英文字符宽度计,整体大于约 16 个字符视为可能溢出
|
||
let width = 0;
|
||
for (const ch of String(name)) {
|
||
// CJK 范围
|
||
if (/[ -鿿]/.test(ch)) width += 2;
|
||
else width += 1;
|
||
}
|
||
return width > 14;
|
||
},
|
||
|
||
parseBillingMethod(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) {
|
||
return [{ name: '计费方式', price: '按量计费', desc: '详情以后端配置为准' }];
|
||
}
|
||
return text
|
||
.split(/\n+/)
|
||
.map(line => line.replace(/^\s*[-–—]\s*/, '').trim())
|
||
.filter(Boolean)
|
||
.map(line => {
|
||
const match = line.match(/^([^::]+)[::]\s*([^\[]+)(?:\[(.+)\])?$/);
|
||
if (!match) {
|
||
return { name: '计费说明', price: line, desc: '' };
|
||
}
|
||
return {
|
||
name: match[1].trim(),
|
||
price: match[2].trim(),
|
||
desc: (match[3] || '').trim()
|
||
};
|
||
});
|
||
},
|
||
|
||
getProviderInitial(provider) {
|
||
return provider ? provider.slice(0, 1) : 'M';
|
||
},
|
||
|
||
getModelLogoUrl(modelLogo) {
|
||
if (!modelLogo) return '';
|
||
if (/^https?:\/\//.test(modelLogo) || modelLogo.startsWith('data:') || modelLogo.startsWith('blob:')) {
|
||
return modelLogo;
|
||
}
|
||
return `${this.IMG_URL}${modelLogo}`;
|
||
},
|
||
|
||
handleModelLogoError(product) {
|
||
this.$set(product, 'logoLoadFailed', true);
|
||
},
|
||
|
||
normalizeTokenConfigList(value) {
|
||
if (!value) return [];
|
||
let parsedValue = value;
|
||
if (typeof value === 'string') {
|
||
try {
|
||
parsedValue = JSON.parse(value);
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
}
|
||
if (Array.isArray(parsedValue)) {
|
||
return parsedValue.map(item => ({
|
||
name: item.name || item.label || item.key || '',
|
||
value: item.value || item.text || item.content || ''
|
||
})).filter(item => item.name || item.value);
|
||
}
|
||
if (parsedValue && typeof parsedValue === 'object') {
|
||
return Object.keys(parsedValue).map(key => ({ name: key, value: parsedValue[key] }));
|
||
}
|
||
return [];
|
||
},
|
||
|
||
getTokenFeatureTags(model) {
|
||
const capabilities = this.normalizeTokenConfigList(model.capabilities);
|
||
const highlights = this.normalizeTokenConfigList(model.highlights);
|
||
const inputType = capabilities.find(item => item.name === '输入类型');
|
||
const outputType = capabilities.find(item => item.name === '输出类型');
|
||
const highlight = highlights[0];
|
||
return [
|
||
{
|
||
text: inputType && inputType.value ? inputType.value : (model.model_type || '模型能力'),
|
||
icon: 'el-icon-view',
|
||
type: 'green'
|
||
},
|
||
{
|
||
text: outputType && outputType.value ? outputType.value : (highlight && (highlight.value || highlight.name)) || '智能生成',
|
||
icon: 'el-icon-magic-stick',
|
||
type: 'purple'
|
||
},
|
||
|
||
].filter(item => item.text);
|
||
},
|
||
|
||
isModelExperienceEnabled(model) {
|
||
return Number(model && model.experience) === 1;
|
||
},
|
||
|
||
goModelApiDocument(model) {
|
||
this.cacheTokenMarketModel(model);
|
||
this.$router.push({
|
||
name: 'modelApiDocument',
|
||
query: {
|
||
id: model.id,
|
||
model_id: model.id,
|
||
from: 'tokenMarket',
|
||
category: 'TOKEN市集'
|
||
}
|
||
});
|
||
},
|
||
|
||
goModelExperience(model) {
|
||
if (!this.isModelExperienceEnabled(model)) return;
|
||
if (!this.loginState) {
|
||
this.$message.warning('请先登录再进行体验哦')
|
||
return;
|
||
}
|
||
this.cacheTokenMarketModel(model);
|
||
this.$router.push({
|
||
name: 'modelExperience',
|
||
query: {
|
||
id: model.id,
|
||
model_id: model.id,
|
||
from: 'tokenMarket',
|
||
category: 'TOKEN市集'
|
||
}
|
||
});
|
||
},
|
||
|
||
cacheTokenMarketModel(model) {
|
||
if (!model) return;
|
||
sessionStorage.setItem('tokenMarketSelectedModel', JSON.stringify(model));
|
||
},
|
||
|
||
async loadNavData() {
|
||
try {
|
||
const baseUrl = window.location.href.split('#')[0];
|
||
const hostname = window.location.hostname
|
||
const homePath = hostname.includes('ncmatch.cn') || hostname.includes('zgcopc.opencomputing.cn')
|
||
? '/ncmatchHome/index'
|
||
: '/homePage/index';
|
||
const response = await reqNavList({ url_link: `${baseUrl}#${homePath}` });
|
||
if (response.status && response.data.product_service) {
|
||
response.data.product_service.forEach(category => {
|
||
if (this.isTokenMarketCategory(category.firTitle)) {
|
||
this.setTokenMarketData(category);
|
||
}
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("加载模型数据失败:", error);
|
||
}
|
||
},
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
// ====== 配色变量 ======
|
||
@primary: #1e6fff;
|
||
@primary-light: #eef5ff;
|
||
@primary-lighter: #f0f7ff;
|
||
@primary-deep: #155eef;
|
||
@text-dark: #1f2937;
|
||
@text-mid: #475467;
|
||
@text-soft: #98a2b3;
|
||
@border: #edf1f7;
|
||
@border-soft: #f1f5f9;
|
||
@bg-card: #ffffff;
|
||
@shadow-card: 0 2px 12px rgba(31, 45, 61, 0.06);
|
||
@shadow-hover: 0 0 0 3px rgba(30, 111, 255, 0.12), 0 16px 32px rgba(30, 111, 255, 0.16);
|
||
|
||
.token-market-page {
|
||
display: flex;
|
||
gap: 24px;
|
||
padding: 24px 32px 40px;
|
||
min-height: calc(100vh - 80px);
|
||
background:
|
||
radial-gradient(800px circle at 8% 0%, rgba(30, 111, 255, 0.06) 0%, transparent 40%),
|
||
radial-gradient(700px circle at 95% 8%, rgba(125, 105, 255, 0.05) 0%, transparent 42%),
|
||
linear-gradient(180deg, #f5f8ff 0%, #ffffff 60%);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* ==================== 侧栏 ==================== */
|
||
.menu-sidebar {
|
||
width: 320px;
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 80px;
|
||
height: calc(100vh - 80px - 48px); // 撑满整屏高度,48px = 上下内边距总和
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20px 18px;
|
||
background: rgba(255, 255, 255, 0.86);
|
||
border: 1px solid rgba(237, 241, 247, 0.8);
|
||
border-radius: 18px;
|
||
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.05);
|
||
backdrop-filter: blur(10px);
|
||
box-sizing: border-box;
|
||
transition: box-shadow 0.3s;
|
||
overflow: hidden;
|
||
|
||
&:hover {
|
||
box-shadow: 0 12px 32px rgba(31, 45, 61, 0.08);
|
||
}
|
||
}
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.sidebar-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0;
|
||
color: @text-dark;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.3px;
|
||
|
||
i {
|
||
color: @primary;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.reset-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 26px;
|
||
padding: 0 10px;
|
||
color: @primary;
|
||
background: @primary-lighter;
|
||
border: 1px solid #c7defb;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.2s;
|
||
|
||
&:hover {
|
||
background: @primary-light;
|
||
border-color: @primary;
|
||
transform: scale(1.04);
|
||
}
|
||
|
||
i {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.product-search {
|
||
margin-bottom: 18px;
|
||
|
||
:deep(.el-input__inner) {
|
||
height: 40px;
|
||
line-height: 40px;
|
||
color: @text-dark;
|
||
font-size: 13px;
|
||
background: rgba(248, 250, 252, 0.7);
|
||
border: 1px solid @border;
|
||
border-radius: 12px;
|
||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||
|
||
&:focus {
|
||
background: #fff;
|
||
border-color: @primary;
|
||
box-shadow: 0 0 0 4px rgba(30, 111, 255, 0.12);
|
||
}
|
||
|
||
&::placeholder {
|
||
color: @text-soft;
|
||
}
|
||
}
|
||
|
||
:deep(.el-input__prefix) {
|
||
left: 12px;
|
||
|
||
i {
|
||
color: @text-soft;
|
||
font-size: 14px;
|
||
line-height: 40px;
|
||
}
|
||
}
|
||
|
||
:deep(.el-input__suffix) {
|
||
right: 10px;
|
||
}
|
||
}
|
||
|
||
// 滚动区:撑满侧栏剩余高度,内容超出时出现滚动条
|
||
.filter-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #d0d7e2 transparent;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: #d0d7e2;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.product-search {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.token-filter-section {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.filter-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
padding-left: 2px;
|
||
|
||
h4 {
|
||
margin: 0;
|
||
color: @text-soft;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
text-transform: uppercase;
|
||
}
|
||
}
|
||
|
||
.filter-count {
|
||
padding: 1px 7px;
|
||
color: @primary;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
background: @primary-light;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.token-filter-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px 6px;
|
||
}
|
||
|
||
.filter-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
max-width: 100%;
|
||
height: 28px;
|
||
padding: 0 12px;
|
||
color: @text-mid;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||
|
||
// tooltip 内的 label
|
||
:deep(.el-tooltip) {
|
||
max-width: 100%;
|
||
display: inline-flex;
|
||
}
|
||
|
||
.chip-label {
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&:hover {
|
||
color: @primary;
|
||
background: @primary-lighter;
|
||
border-color: #bcd6fb;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
&.active {
|
||
color: #fff;
|
||
font-weight: 600;
|
||
background: linear-gradient(135deg, @primary, #4d86ff);
|
||
border-color: transparent;
|
||
box-shadow: 0 4px 10px rgba(30, 111, 255, 0.28);
|
||
|
||
&:hover {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, @primary-deep, #4170ee);
|
||
border-color: transparent;
|
||
transform: translateY(-1px);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ==================== 主内容区 ==================== */
|
||
.main-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
|
||
.summary-text {
|
||
color: @text-mid;
|
||
font-size: 14px;
|
||
|
||
strong {
|
||
margin: 0 2px;
|
||
color: @primary;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
|
||
.active-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.active-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 26px;
|
||
padding: 0 6px 0 10px;
|
||
color: @primary;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: @primary-light;
|
||
border: 1px solid #c7defb;
|
||
border-radius: 999px;
|
||
|
||
i:first-child {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.close-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-left: 2px;
|
||
color: @primary;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
transition: background 0.2s, color 0.2s;
|
||
|
||
&:hover {
|
||
color: #fff;
|
||
background: @primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
.token-market-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 18px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
/* ==================== 卡片 ==================== */
|
||
.token-market-card {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden; // 裁剪 hover-actions 底部圆角
|
||
min-height: 240px;
|
||
padding: 16px;
|
||
background: @bg-card;
|
||
border: 1px solid rgba(228, 234, 243, 0.9);
|
||
border-radius: 14px;
|
||
box-shadow: @shadow-card;
|
||
cursor: pointer;
|
||
transition: border-color 0.25s, transform 0.25s, box-shadow 0.25s;
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 140px;
|
||
height: 140px;
|
||
background: radial-gradient(circle at 100% 0%, rgba(30, 111, 255, 0.08) 0%, transparent 60%);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: #8bbcff;
|
||
transform: translateY(-4px);
|
||
box-shadow: @shadow-hover;
|
||
|
||
&::before {
|
||
opacity: 1;
|
||
}
|
||
|
||
.token-hover-actions {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.token-card-top h3 {
|
||
color: @primary;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 卡片顶部 */
|
||
.token-card-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.token-provider-avatar {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
width: 42px;
|
||
height: 42px;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, #60a5fa, #3b82f6, #2563eb);
|
||
border-radius: 10px;
|
||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.28);
|
||
overflow: hidden;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
background: #fff;
|
||
}
|
||
}
|
||
|
||
.token-title-group {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
h3 {
|
||
margin: 0;
|
||
color: @text-dark;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
transition: color 0.25s;
|
||
}
|
||
|
||
p {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin: 3px 0 0;
|
||
color: @text-soft;
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
|
||
i {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 计费区 */
|
||
.token-price-grid {
|
||
flex: 1;
|
||
max-height: 178px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-bottom: 12px;
|
||
padding-right: 2px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #d0d7e2 transparent;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: #d0d7e2;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.token-price-item {
|
||
padding: 8px 12px;
|
||
background: linear-gradient(180deg, #f8fafc 0%, #f3f7fb 100%);
|
||
border: 1px solid @border-soft;
|
||
border-radius: 10px;
|
||
transition: background 0.2s, border-color 0.2s;
|
||
|
||
&:hover {
|
||
background: linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
|
||
border-color: #d6e4fb;
|
||
}
|
||
}
|
||
|
||
.price-name {
|
||
display: block;
|
||
margin-bottom: 3px;
|
||
color: @text-soft;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.4px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.price-value {
|
||
display: block;
|
||
color: @text-dark;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.price-desc {
|
||
display: block;
|
||
margin-top: 3px;
|
||
color: @text-soft;
|
||
font-size: 11px;
|
||
font-style: normal;
|
||
line-height: 1.4;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 底部标签 */
|
||
.token-card-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.token-feature-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
width: 100%;
|
||
|
||
span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
max-width: 120px;
|
||
height: 22px;
|
||
padding: 0 8px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
transition: transform 0.2s;
|
||
|
||
&:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
i {
|
||
font-size: 11px;
|
||
}
|
||
|
||
&.green {
|
||
color: #047857;
|
||
background: #d1fae5;
|
||
}
|
||
|
||
&.purple {
|
||
color: #5b21b6;
|
||
background: #ede9fe;
|
||
}
|
||
|
||
&.blue {
|
||
color: #1e40af;
|
||
background: #dbeafe;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* hover 操作栏 */
|
||
.token-hover-actions {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
z-index: 2;
|
||
display: flex;
|
||
gap: 6px;
|
||
padding: 8px 10px;
|
||
opacity: 0;
|
||
background: rgba(255, 255, 255, 0.97);
|
||
border-top: 1px solid @border;
|
||
box-shadow: 0 -6px 16px rgba(31, 45, 61, 0.04);
|
||
backdrop-filter: blur(10px);
|
||
transform: translateY(100%);
|
||
transition: opacity 0.22s, transform 0.22s;
|
||
|
||
button {
|
||
flex: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
color: @text-mid;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
background: #fff;
|
||
border: 1px solid #d8e0ee;
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
transition: color 0.18s, border-color 0.18s, background 0.18s, transform 0.18s;
|
||
|
||
&:hover {
|
||
color: @primary;
|
||
border-color: #9ec5ff;
|
||
background: @primary-lighter;
|
||
transform: translateY(-1px);
|
||
}
|
||
}
|
||
|
||
.primary-action {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, @primary, #5d8dff);
|
||
border-color: transparent;
|
||
box-shadow: 0 2px 6px rgba(30, 111, 255, 0.25);
|
||
|
||
&:hover {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, @primary-deep, #4a7aef);
|
||
box-shadow: 0 4px 10px rgba(30, 111, 255, 0.35);
|
||
}
|
||
|
||
&.disabled,
|
||
&:disabled {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #cbd5e1, #b8c2cf);
|
||
box-shadow: none;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 80px 20px;
|
||
background: #fff;
|
||
border: 2px dashed #d6e0ee;
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.empty-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 72px;
|
||
height: 72px;
|
||
margin-bottom: 16px;
|
||
color: @text-soft;
|
||
font-size: 32px;
|
||
background: linear-gradient(135deg, @primary-lighter, #f1f5f9);
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.empty-text {
|
||
margin: 0 0 8px;
|
||
color: @text-dark;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.empty-hint {
|
||
color: @text-soft;
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ==================== 响应式 ==================== */
|
||
@media (max-width: 1280px) {
|
||
.token-market-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.token-market-page {
|
||
flex-direction: column;
|
||
padding: 16px;
|
||
}
|
||
|
||
.menu-sidebar {
|
||
width: 100%;
|
||
position: static;
|
||
height: auto;
|
||
max-height: 60vh;
|
||
}
|
||
|
||
.token-market-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|