mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(content-studio): 内容日历去重 + 毛玻璃改版 + 外框线加实
This commit is contained in:
parent
ac4b277492
commit
9d6509840c
@ -31,6 +31,9 @@ public class ContentItemService {
|
||||
/** Ignore same-topic rows created within this window, so a record→check in one
|
||||
* run doesn't flag itself as a repeat. */
|
||||
private static final long SELF_MATCH_GUARD_MINUTES = 2;
|
||||
/** Re-packaging the same topic within this window updates the existing ledger
|
||||
* row instead of inserting a duplicate (the agent may call a package tool twice). */
|
||||
private static final long RECORD_DEDUP_MINUTES = 10;
|
||||
|
||||
private final ContentItemMapper contentItemMapper;
|
||||
|
||||
@ -50,16 +53,49 @@ public class ContentItemService {
|
||||
.orderByDesc(ContentItemEntity::getCreateTime));
|
||||
}
|
||||
|
||||
/** Record a produced piece; returns the new item id. */
|
||||
/**
|
||||
* Record a produced piece; returns its item id. Idempotent within a short
|
||||
* window: re-packaging the same topic on the same platform (e.g. the agent
|
||||
* called a package tool twice) updates the existing row instead of inserting
|
||||
* a duplicate. A published row is never overwritten.
|
||||
*/
|
||||
public Long record(Long workspaceId, String platform, String topic, String title,
|
||||
String status, String previewUrl, String externalRef) {
|
||||
String plat = platform.trim().toLowerCase();
|
||||
String fp = fingerprint(topic != null ? topic : title);
|
||||
String resolvedStatus = status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase();
|
||||
|
||||
ContentItemEntity existing = contentItemMapper.selectOne(new LambdaQueryWrapper<ContentItemEntity>()
|
||||
.eq(ContentItemEntity::getPlatform, plat)
|
||||
.eq(ContentItemEntity::getTopicFingerprint, fp)
|
||||
.ne(ContentItemEntity::getStatus, "published")
|
||||
.ge(ContentItemEntity::getCreateTime, LocalDateTime.now().minusMinutes(RECORD_DEDUP_MINUTES))
|
||||
.orderByDesc(ContentItemEntity::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (existing != null) {
|
||||
if (title != null && !title.isBlank()) {
|
||||
existing.setTitle(title.trim());
|
||||
}
|
||||
if (previewUrl != null) {
|
||||
existing.setPreviewUrl(previewUrl);
|
||||
}
|
||||
if (externalRef != null) {
|
||||
existing.setExternalRef(externalRef);
|
||||
}
|
||||
existing.setStatus(resolvedStatus);
|
||||
contentItemMapper.updateById(existing);
|
||||
log.info("[ContentItem] re-package dedup: updated id={} platform={} topic='{}'",
|
||||
existing.getId(), plat, topic);
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
ContentItemEntity e = new ContentItemEntity();
|
||||
e.setWorkspaceId(workspaceId);
|
||||
e.setPlatform(platform.trim().toLowerCase());
|
||||
e.setPlatform(plat);
|
||||
e.setTopic(topic != null ? topic.trim() : null);
|
||||
e.setTopicFingerprint(fingerprint(topic != null ? topic : title));
|
||||
e.setTopicFingerprint(fp);
|
||||
e.setTitle(title != null ? title.trim() : null);
|
||||
e.setStatus(status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase());
|
||||
e.setStatus(resolvedStatus);
|
||||
e.setPreviewUrl(previewUrl);
|
||||
e.setExternalRef(externalRef);
|
||||
contentItemMapper.insert(e);
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
package vip.mate.content.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.content.model.ContentItemEntity;
|
||||
import vip.mate.content.repository.ContentItemMapper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Pin {@link ContentItemService#record} idempotency: re-packaging the same topic
|
||||
* on the same platform within the dedup window updates the existing ledger row
|
||||
* instead of inserting a duplicate; a genuinely new topic inserts a fresh row.
|
||||
*/
|
||||
class ContentItemServiceTest {
|
||||
|
||||
private ContentItemMapper mapper;
|
||||
private ContentItemService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(ContentItemMapper.class);
|
||||
service = new ContentItemService(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("re-package of a recent same topic updates the existing row, no duplicate insert")
|
||||
void rePackageUpdatesInsteadOfInserting() {
|
||||
ContentItemEntity existing = new ContentItemEntity();
|
||||
existing.setId(555L);
|
||||
existing.setStatus("packaged");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
Long id = service.record(1L, "gzh", "科技数码选题", "新标题", "packaged", "http://p/2", null);
|
||||
|
||||
assertEquals(555L, id, "should return the existing row's id");
|
||||
verify(mapper, never()).insert(any(ContentItemEntity.class));
|
||||
verify(mapper, times(1)).updateById(existing);
|
||||
assertEquals("新标题", existing.getTitle(), "title refreshed on the existing row");
|
||||
assertEquals("http://p/2", existing.getPreviewUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a new topic (no recent match) inserts a fresh row")
|
||||
void newTopicInserts() {
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
service.record(1L, "xhs", "全新选题", "标题", "packaged", "http://p/1", null);
|
||||
|
||||
verify(mapper, times(1)).insert(any(ContentItemEntity.class));
|
||||
verify(mapper, never()).updateById(any(ContentItemEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fingerprint falls back to title when topic is null")
|
||||
void fingerprintFallsBackToTitle() {
|
||||
assertEquals(ContentItemService.fingerprint("我的标题"),
|
||||
ContentItemService.fingerprint("我的标题"));
|
||||
assertNotEquals(ContentItemService.fingerprint("甲"), ContentItemService.fingerprint("乙"));
|
||||
}
|
||||
}
|
||||
@ -5,86 +5,88 @@
|
||||
<p class="subtitle">{{ t('contentCalendar.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status summary cards -->
|
||||
<!-- Status summary cards (glass) -->
|
||||
<div class="summary-cards">
|
||||
<div
|
||||
<button
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="summary-card"
|
||||
:class="{ active: statusFilter === card.key }"
|
||||
class="summary-card glass"
|
||||
:class="[{ active: statusFilter === card.key }, `accent-${card.tone}`]"
|
||||
@click="toggleStatus(card.key)"
|
||||
>
|
||||
<span class="card-dot" :class="`dot-${card.tone}`"></span>
|
||||
<div class="card-count">{{ card.count }}</div>
|
||||
<div class="card-label">{{ card.label }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<el-select v-model="platformFilter" :placeholder="t('contentCalendar.allPlatforms')" clearable @change="reload">
|
||||
<!-- Filter bar (glass) -->
|
||||
<div class="filters glass">
|
||||
<el-select v-model="platformFilter" :placeholder="t('contentCalendar.allPlatforms')" clearable @change="reload" class="platform-select">
|
||||
<el-option label="公众号" value="gzh" />
|
||||
<el-option label="小红书" value="xhs" />
|
||||
</el-select>
|
||||
<el-button :icon="RefreshIcon" @click="reload">{{ t('contentCalendar.refresh') }}</el-button>
|
||||
<button class="refresh-btn" @click="reload">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
{{ t('contentCalendar.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<el-table :data="rows" v-loading="loading" stripe class="calendar-table">
|
||||
<el-table-column :label="t('contentCalendar.platform')" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.platform === 'gzh' ? 'success' : 'danger'">
|
||||
{{ row.platform === 'gzh' ? '公众号' : '小红书' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.colTitle')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<a v-if="row.previewUrl" :href="row.previewUrl" target="_blank" rel="noopener" class="title-link">
|
||||
{{ row.title || '(无标题)' }}
|
||||
</a>
|
||||
<span v-else>{{ row.title || '(无标题)' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="topic" :label="t('contentCalendar.topic')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('contentCalendar.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.createTime')" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.publishTime')" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.publishTime) || '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- Table (glass card) -->
|
||||
<div class="table-card glass">
|
||||
<el-table :data="rows" v-loading="loading" class="calendar-table" :show-header="true">
|
||||
<el-table-column :label="t('contentCalendar.platform')" width="110">
|
||||
<template #default="{ row }">
|
||||
<span class="platform-pill" :class="row.platform === 'gzh' ? 'pill-gzh' : 'pill-xhs'">
|
||||
{{ row.platform === 'gzh' ? '公众号' : '小红书' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.colTitle')" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<a v-if="row.previewUrl" :href="row.previewUrl" target="_blank" rel="noopener" class="title-link">
|
||||
{{ row.title || '(无标题)' }}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="ext-icon"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
<span v-else>{{ row.title || '(无标题)' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="topic" :label="t('contentCalendar.topic')" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }"><span class="topic-text">{{ row.topic || '—' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="status-chip" :class="`chip-${row.status || 'unknown'}`">{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.createTime')" width="160">
|
||||
<template #default="{ row }"><span class="time-text">{{ formatTime(row.createTime) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('contentCalendar.publishTime')" width="160">
|
||||
<template #default="{ row }"><span class="time-text">{{ formatTime(row.publishTime) || '—' }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { contentItemApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Minimal inline refresh icon (avoids an extra import).
|
||||
const RefreshIcon = () =>
|
||||
h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }, [
|
||||
h('polyline', { points: '23 4 23 10 17 10' }),
|
||||
h('path', { d: 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10' }),
|
||||
])
|
||||
|
||||
interface ContentItem {
|
||||
id: string | number
|
||||
platform: string
|
||||
@ -106,18 +108,15 @@ const statusFilter = ref('')
|
||||
const summary = ref<Record<string, number>>({})
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ key: '', label: t('contentCalendar.total'), count: summary.value.total ?? 0 },
|
||||
{ key: 'packaged', label: t('contentCalendar.packaged'), count: summary.value.packaged ?? 0 },
|
||||
{ key: 'draft', label: t('contentCalendar.draft'), count: summary.value.draft ?? 0 },
|
||||
{ key: 'published', label: t('contentCalendar.published'), count: summary.value.published ?? 0 },
|
||||
{ key: '', label: t('contentCalendar.total'), count: summary.value.total ?? 0, tone: 'primary' },
|
||||
{ key: 'packaged', label: t('contentCalendar.packaged'), count: summary.value.packaged ?? 0, tone: 'amber' },
|
||||
{ key: 'draft', label: t('contentCalendar.draft'), count: summary.value.draft ?? 0, tone: 'gray' },
|
||||
{ key: 'published', label: t('contentCalendar.published'), count: summary.value.published ?? 0, tone: 'green' },
|
||||
])
|
||||
|
||||
function statusLabel(s?: string) {
|
||||
return t(`contentCalendar.st_${s || 'unknown'}`)
|
||||
}
|
||||
function statusTagType(s?: string) {
|
||||
return s === 'published' ? 'success' : s === 'draft' ? 'info' : s === 'failed' ? 'danger' : 'warning'
|
||||
}
|
||||
function formatTime(v?: string) {
|
||||
if (!v) return ''
|
||||
return v.replace('T', ' ').slice(0, 16)
|
||||
@ -145,7 +144,6 @@ async function reload() {
|
||||
rows.value = data.records || data.list || []
|
||||
total.value = Number(data.total ?? rows.value.length)
|
||||
} catch {
|
||||
// Degrade gracefully (e.g. backend not yet restarted with the new endpoint).
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
@ -170,22 +168,94 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-calendar { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||
.page-header h2 { margin: 0 0 4px; font-size: 20px; }
|
||||
.subtitle { margin: 0 0 16px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.summary-cards { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.summary-card {
|
||||
flex: 1; min-width: 120px; padding: 14px 16px; border-radius: 10px;
|
||||
background: var(--el-fill-color-light); cursor: pointer; transition: all .15s;
|
||||
border: 1px solid transparent;
|
||||
.content-calendar { padding: 24px 24px 40px; max-width: 1180px; margin: 0 auto; }
|
||||
.page-header h2 { margin: 0 0 4px; font-size: 21px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.subtitle { margin: 0 0 20px; color: var(--mc-text-tertiary); font-size: 13px; }
|
||||
|
||||
/* Frosted-glass surface shared by cards, filter bar, and table wrapper. */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(16px) saturate(1.15);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.15);
|
||||
border: 1px solid var(--mc-border);
|
||||
box-shadow: var(--mc-shadow-soft);
|
||||
}
|
||||
.summary-card:hover { background: var(--el-fill-color); }
|
||||
.summary-card.active { border-color: var(--el-color-primary); }
|
||||
.card-count { font-size: 24px; font-weight: 700; }
|
||||
.card-label { font-size: 13px; color: var(--el-text-color-secondary); margin-top: 2px; }
|
||||
.filters { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.calendar-table { width: 100%; }
|
||||
.title-link { color: var(--el-color-primary); text-decoration: none; }
|
||||
.title-link:hover { text-decoration: underline; }
|
||||
.pager { margin-top: 14px; display: flex; justify-content: flex-end; }
|
||||
html.dark .glass { background: rgba(255, 255, 255, 0.045); border-color: rgba(255, 255, 255, 0.12); }
|
||||
|
||||
/* Summary cards */
|
||||
.summary-cards { display: flex; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.summary-card {
|
||||
position: relative; flex: 1; min-width: 130px; padding: 16px 18px; border-radius: 16px;
|
||||
cursor: pointer; text-align: left; font-family: inherit;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.summary-card:hover { transform: translateY(-3px); box-shadow: var(--mc-shadow-medium); }
|
||||
.summary-card.active { border-color: var(--mc-primary); box-shadow: 0 0 0 3px var(--mc-primary-bg), var(--mc-shadow-medium); }
|
||||
.card-dot { position: absolute; top: 16px; right: 16px; width: 9px; height: 9px; border-radius: 50%; }
|
||||
.dot-primary { background: var(--mc-primary); }
|
||||
.dot-amber { background: #f59e0b; }
|
||||
.dot-gray { background: var(--mc-text-tertiary); }
|
||||
.dot-green { background: #22a06b; }
|
||||
.card-count { font-size: 28px; font-weight: 800; line-height: 1.1; color: var(--mc-text-primary); }
|
||||
.card-label { font-size: 13px; color: var(--mc-text-secondary); margin-top: 4px; }
|
||||
/* Subtle tinted glow per tone, layered under the glass */
|
||||
.summary-card.accent-primary::before,
|
||||
.summary-card.accent-amber::before,
|
||||
.summary-card.accent-gray::before,
|
||||
.summary-card.accent-green::before {
|
||||
content: ''; position: absolute; inset: 0; opacity: 0.5; pointer-events: none;
|
||||
background: radial-gradient(120% 90% at 100% 0%, var(--tone-glow, transparent), transparent 60%);
|
||||
}
|
||||
.accent-primary { --tone-glow: rgba(217, 119, 87, 0.14); }
|
||||
.accent-amber { --tone-glow: rgba(245, 158, 11, 0.13); }
|
||||
.accent-gray { --tone-glow: rgba(120, 120, 120, 0.1); }
|
||||
.accent-green { --tone-glow: rgba(34, 160, 107, 0.13); }
|
||||
|
||||
/* Filter bar */
|
||||
.filters { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 14px; margin-bottom: 16px; }
|
||||
.platform-select { width: 170px; }
|
||||
.refresh-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 10px;
|
||||
border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-primary);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.refresh-btn:hover { background: var(--mc-primary-bg); border-color: var(--mc-primary-light); color: var(--mc-primary); }
|
||||
|
||||
/* Table card */
|
||||
.table-card { border-radius: 16px; padding: 8px 8px 14px; }
|
||||
.calendar-table { width: 100%; background: transparent; }
|
||||
.calendar-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
.calendar-table :deep(.el-table),
|
||||
.calendar-table :deep(.el-table__body-wrapper),
|
||||
.calendar-table :deep(.el-table tr),
|
||||
.calendar-table :deep(.el-table th.el-table__cell),
|
||||
.calendar-table :deep(.el-table td.el-table__cell) { background: transparent; }
|
||||
.calendar-table :deep(.el-table th.el-table__cell) {
|
||||
font-weight: 600; color: var(--mc-text-tertiary); font-size: 12.5px; border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.calendar-table :deep(.el-table td.el-table__cell) { border-bottom: 1px solid var(--mc-border-light); }
|
||||
.calendar-table :deep(.el-table__row:hover > td.el-table__cell) { background: var(--mc-primary-bg) !important; }
|
||||
|
||||
/* Platform pills — gradient */
|
||||
.platform-pill { display: inline-block; padding: 3px 11px; border-radius: 20px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
|
||||
.pill-gzh { background: linear-gradient(135deg, #2e8b6a, #184a45); }
|
||||
.pill-xhs { background: linear-gradient(135deg, #ff6a86, #e0345a); }
|
||||
|
||||
/* Status chips */
|
||||
.status-chip { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.chip-packaged { color: #b26a00; background: rgba(245, 158, 11, 0.14); }
|
||||
.chip-published { color: #157a52; background: rgba(34, 160, 107, 0.15); }
|
||||
.chip-draft { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
|
||||
.chip-failed { color: var(--mc-danger); background: var(--mc-danger-bg); }
|
||||
.chip-unknown { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
|
||||
|
||||
.title-link { display: inline-flex; align-items: center; gap: 5px; color: var(--mc-text-primary); text-decoration: none; font-weight: 500; }
|
||||
.title-link:hover { color: var(--mc-primary); }
|
||||
.title-link .ext-icon { opacity: 0; transition: opacity 0.15s; }
|
||||
.title-link:hover .ext-icon { opacity: 0.7; }
|
||||
.topic-text { color: var(--mc-text-secondary); font-size: 13px; }
|
||||
.time-text { color: var(--mc-text-tertiary); font-size: 12.5px; }
|
||||
|
||||
.pager { margin-top: 14px; display: flex; justify-content: flex-end; padding: 0 8px; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user