- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除 纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护) - AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐 - index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、 形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖 Element Plus 主色 - 登录页、业务页、结算页、工作台、审批页等视图随新变量适配 验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。 Co-Authored-By: Claude Code <noreply@anthropic.com>
671 lines
18 KiB
Vue
671 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import * as echarts from 'echarts/core'
|
||
import { BarChart, LineChart } from 'echarts/charts'
|
||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||
import { SVGRenderer } from 'echarts/renderers'
|
||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||
|
||
import * as dashboardApi from '@/api/dashboard'
|
||
import type { DashboardData, TrendPoint } from '@/api/dashboard'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, SVGRenderer])
|
||
|
||
/**
|
||
* 工作台(M4 ⑧):经营绩效总览 / 待办事项 / 风险预警 / 最近动态。
|
||
*
|
||
* - 四区块由一次聚合请求返回(后端实时派生,见 dashboard_service 注释)
|
||
* - 总览图表严格按设计预览:SVG 渲染、无网格线、靛蓝渐变;
|
||
* **必须显式设置 fontFamily**——ECharts 在 Windows 默认微软雅黑(字体合规红线)
|
||
* - ⑥ T+ 同步未建:同步失败待办/同步预警显示「模块未启用」,不显示 0 条
|
||
*/
|
||
|
||
const router = useRouter()
|
||
|
||
const CHART_FONT =
|
||
"'Noto Sans SC','Source Han Sans SC','HarmonyOS Sans SC','PingFang SC',sans-serif"
|
||
|
||
const SETTLEMENT_STATUS_LABELS: Record<number, string> = {
|
||
1: '草稿',
|
||
2: '待审批',
|
||
3: '已锁定',
|
||
4: '已驳回',
|
||
}
|
||
const ORDER_TYPE_LABELS: Record<number, string> = {
|
||
1: '二次分配方案',
|
||
2: '月度结算',
|
||
3: '调整单',
|
||
}
|
||
|
||
const month = ref(currentMonth())
|
||
const data = ref<DashboardData | null>(null)
|
||
const loading = ref(false)
|
||
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
||
let loadSeq = 0
|
||
|
||
const perfChartRef = ref<HTMLDivElement | null>(null)
|
||
const receiptChartRef = ref<HTMLDivElement | null>(null)
|
||
let perfChart: ECharts | null = null
|
||
let receiptChart: ECharts | null = null
|
||
|
||
function currentMonth(): string {
|
||
const now = new Date()
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
const seq = ++loadSeq
|
||
loading.value = true
|
||
try {
|
||
const result = await dashboardApi.getDashboard({ month: month.value })
|
||
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
||
data.value = result
|
||
await nextTick()
|
||
renderCharts()
|
||
} catch (error) {
|
||
if (seq === loadSeq) handledError(error)
|
||
} finally {
|
||
if (seq === loadSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
function renderCharts(): void {
|
||
const trend = data.value?.overview.trend ?? []
|
||
if (perfChartRef.value) {
|
||
perfChart ??= echarts.init(perfChartRef.value, undefined, { renderer: 'svg' })
|
||
perfChart.setOption(performanceOption(trend))
|
||
}
|
||
if (receiptChartRef.value) {
|
||
receiptChart ??= echarts.init(receiptChartRef.value, undefined, { renderer: 'svg' })
|
||
receiptChart.setOption(receiptOption(trend))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 图表轴:设计稿 §05 的口径 —— 轴标签 9px 弱墨、无 y 网格线、无刻度,
|
||
* 只留一条极淡的 x 轴线。字号必须显式给,ECharts 默认 12px 会把
|
||
* 150px 高的图压满标签。
|
||
*/
|
||
function axisCommon(trend: TrendPoint[]) {
|
||
return {
|
||
xAxis: {
|
||
type: 'category' as const,
|
||
data: trend.map((item) => item.month.slice(5)),
|
||
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
||
axisTick: { show: false },
|
||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||
},
|
||
yAxis: {
|
||
type: 'value' as const,
|
||
splitLine: { show: false },
|
||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 竖向渐变(从上到下),设计稿要求柱体与折线面积都用「上亮下暗」的品牌渐变。
|
||
* color-mix 与 canvas 渐变对象在 SVG renderer 下表现一致,故直接用 echarts 的
|
||
* linear 渐变描述对象,不依赖 canvas API。
|
||
*/
|
||
function verticalGradient(top: string, bottom: string) {
|
||
return {
|
||
type: 'linear' as const,
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: top },
|
||
{ offset: 1, color: bottom },
|
||
],
|
||
}
|
||
}
|
||
|
||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||
return {
|
||
textStyle: { fontFamily: CHART_FONT },
|
||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
||
...axisCommon(trend),
|
||
series: [
|
||
{
|
||
name: '绩效',
|
||
type: 'line',
|
||
smooth: 0.4,
|
||
symbol: 'none',
|
||
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
||
lineStyle: { color: '#2e4ba8', width: 2 },
|
||
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
||
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
|
||
const lastIndex = trend.length - 1
|
||
return {
|
||
textStyle: { fontFamily: CHART_FONT },
|
||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
||
...axisCommon(trend),
|
||
series: [
|
||
{
|
||
name: '回款',
|
||
type: 'bar',
|
||
barMaxWidth: 22,
|
||
data: trend.map((item, index) => ({
|
||
value: Number(item.receipt_total ?? 0),
|
||
itemStyle: {
|
||
color:
|
||
index === lastIndex
|
||
? verticalGradient('#3a58bc', '#2e4ba8')
|
||
: verticalGradient('#d5def3', '#e8edf8'),
|
||
borderRadius: [3, 3, 0, 0],
|
||
},
|
||
})),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function handleResize(): void {
|
||
perfChart?.resize()
|
||
receiptChart?.resize()
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
perfChart?.dispose()
|
||
receiptChart?.dispose()
|
||
})
|
||
|
||
const activityModule = ref('')
|
||
const activityModules = computed(() => {
|
||
const modules = new Set((data.value?.activities ?? []).map((item) => item.module))
|
||
return [...modules]
|
||
})
|
||
const filteredActivities = computed(() =>
|
||
(data.value?.activities ?? []).filter(
|
||
(item) => !activityModule.value || item.module === activityModule.value,
|
||
),
|
||
)
|
||
|
||
function go(path: string | null): void {
|
||
if (path) void router.push(path)
|
||
}
|
||
|
||
function formatTime(value: string): string {
|
||
return value.replace('T', ' ').slice(0, 16)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-loading="loading">
|
||
<div class="page-head">
|
||
<h1 class="page-title">工作台</h1>
|
||
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<el-date-picker
|
||
v-model="month"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
:clearable="false"
|
||
style="width: 140px"
|
||
@change="load"
|
||
/>
|
||
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
|
||
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
|
||
</div>
|
||
|
||
<div class="bento">
|
||
<!-- 经营绩效总览:KPI -->
|
||
<div class="bento-card">
|
||
<div class="b-eyebrow">本月绩效总额</div>
|
||
<div class="b-num">{{ data?.overview.performance_total ?? '—' }}</div>
|
||
</div>
|
||
<div class="bento-card">
|
||
<div class="b-eyebrow">本月应付绩效</div>
|
||
<div class="b-num">{{ data?.overview.payable_total ?? '—' }}</div>
|
||
</div>
|
||
<div class="bento-card">
|
||
<div class="b-eyebrow">核算人数</div>
|
||
<div class="b-num">{{ data?.overview.person_count ?? '—' }}<small>人</small></div>
|
||
</div>
|
||
<div class="bento-card">
|
||
<div class="b-eyebrow">营销团队</div>
|
||
<div class="b-num">{{ data?.overview.team_count ?? '—' }}<small>个</small></div>
|
||
<div class="b-sub">
|
||
月结:
|
||
{{
|
||
data?.overview.settlement.exists
|
||
? SETTLEMENT_STATUS_LABELS[data.overview.settlement.status ?? 0] ?? '—'
|
||
: '未生成'
|
||
}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 经营绩效总览:图表 -->
|
||
<div class="bento-card b-2x2">
|
||
<div class="b-eyebrow">近 6 个月绩效</div>
|
||
<div ref="perfChartRef" class="chart"></div>
|
||
</div>
|
||
<div class="bento-card b-2x2">
|
||
<div class="b-eyebrow">近 6 个月回款</div>
|
||
<div ref="receiptChartRef" class="chart"></div>
|
||
</div>
|
||
|
||
<!-- 待办事项 -->
|
||
<div class="bento-card b-2x2">
|
||
<div class="b-eyebrow">
|
||
待办事项
|
||
<span class="b-count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||
</div>
|
||
<div v-if="data?.todos.pending_approvals.length" class="todo-list">
|
||
<button
|
||
v-for="item in data.todos.pending_approvals"
|
||
:key="item.order_id"
|
||
class="todo-row"
|
||
type="button"
|
||
@click="go('/system/approvals')"
|
||
>
|
||
<span class="todo-title">
|
||
{{ ORDER_TYPE_LABELS[item.order_type] ?? '审批' }} · {{ item.order_no }}
|
||
</span>
|
||
<span class="todo-meta">{{ item.submitter_name ?? '—' }} · {{ formatTime(item.create_time) }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="b-empty-mini">无待审批事项</div>
|
||
|
||
<div class="b-eyebrow b-eyebrow-gap">
|
||
回款未指定批次
|
||
<span class="b-count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||
</div>
|
||
<div v-if="data?.todos.unassigned_receipts.length" class="todo-list">
|
||
<button
|
||
v-for="item in data.todos.unassigned_receipts"
|
||
:key="item.receipt_id"
|
||
class="todo-row"
|
||
type="button"
|
||
@click="go('/business/receipts')"
|
||
>
|
||
<span class="todo-title">{{ item.receipt_batch_no }} · {{ item.amount }}</span>
|
||
<span class="todo-meta">业务 {{ item.business_no ?? item.business_id }} · {{ item.receipt_date }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="b-empty-mini">无未指定批次的回款</div>
|
||
|
||
<div class="b-eyebrow b-eyebrow-gap">T+ 同步失败</div>
|
||
<div class="b-empty-mini">
|
||
{{ data?.todos.sync_available ? `${data.todos.sync_failure_total} 条待处置` : '同步模块未启用(待 T+ 对接)' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 风险预警 -->
|
||
<div class="bento-card b-2x2">
|
||
<div class="b-eyebrow">
|
||
风险预警
|
||
<span class="b-count">{{ data?.warnings.total ?? 0 }}</span>
|
||
</div>
|
||
<div v-if="data && !data.warnings.enabled" class="b-empty-mini">
|
||
预警已关闭(参数 warning.enabled=0)
|
||
</div>
|
||
<div v-else-if="data?.warnings.items.length" class="warning-list">
|
||
<button
|
||
v-for="item in data.warnings.items"
|
||
:key="`${item.code}-${item.object_id}`"
|
||
class="warning-row"
|
||
type="button"
|
||
@click="go(item.link)"
|
||
>
|
||
<el-tag
|
||
size="small"
|
||
:type="item.level === 'danger' ? 'danger' : 'warning'"
|
||
disable-transitions
|
||
>
|
||
{{ item.level === 'danger' ? '资金' : '时效' }}
|
||
</el-tag>
|
||
<span class="warning-title">{{ item.title }}</span>
|
||
<span class="warning-detail">{{ item.detail }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else class="b-empty-mini">
|
||
暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 /
|
||
审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
|
||
</div>
|
||
<div
|
||
v-if="data && data.warnings.total > data.warnings.items.length"
|
||
class="b-empty-mini"
|
||
>
|
||
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
||
</div>
|
||
<div class="b-eyebrow b-eyebrow-gap">同步异常预警</div>
|
||
<div class="b-empty-mini">
|
||
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近动态 -->
|
||
<div class="bento-card b-full">
|
||
<div class="b-eyebrow">
|
||
最近动态
|
||
<el-select
|
||
v-model="activityModule"
|
||
size="small"
|
||
clearable
|
||
placeholder="全部模块"
|
||
style="width: 140px; margin-left: 12px"
|
||
>
|
||
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</div>
|
||
<div v-if="filteredActivities.length" class="activity-list">
|
||
<div v-for="item in filteredActivities" :key="item.id" class="activity-row">
|
||
<span class="activity-time">{{ formatTime(item.create_time) }}</span>
|
||
<el-tag size="small" :type="item.success === 1 ? 'info' : 'danger'" disable-transitions>
|
||
{{ item.module }} · {{ item.action }}
|
||
</el-tag>
|
||
<span class="activity-summary">{{ item.summary }}</span>
|
||
<span class="activity-user">{{ item.username ?? '—' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="b-empty-mini">暂无动态</div>
|
||
</div>
|
||
|
||
<!-- 常用操作 -->
|
||
<div class="bento-card b-full">
|
||
<div class="b-eyebrow">常用操作</div>
|
||
<div class="b-links">
|
||
<button class="b-op" type="button" @click="go('/business/receipts')">录入回款</button>
|
||
<button class="b-op" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
||
<button class="b-op" type="button" @click="go('/settlements')">月度结算</button>
|
||
<button class="b-op" type="button" @click="go('/reports/personal-detail')">
|
||
报表统计
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||
|
||
.bento {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
grid-auto-rows: 102px;
|
||
gap: 14px;
|
||
}
|
||
|
||
.bento-card {
|
||
position: relative;
|
||
padding: 16px 18px;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||
border: 1px solid var(--ys-line);
|
||
border-radius: var(--ys-radius);
|
||
box-shadow: var(--ys-shadow-card);
|
||
transition:
|
||
transform 180ms var(--ys-ease),
|
||
box-shadow 180ms var(--ys-ease);
|
||
/* 交错入场:45ms 一檔,卡片依次上浮,避免整屏同时闪一下 */
|
||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
||
}
|
||
|
||
.bento > :nth-child(2) {
|
||
animation-delay: 45ms;
|
||
}
|
||
|
||
.bento > :nth-child(3) {
|
||
animation-delay: 90ms;
|
||
}
|
||
|
||
.bento > :nth-child(4) {
|
||
animation-delay: 135ms;
|
||
}
|
||
|
||
.bento > :nth-child(5) {
|
||
animation-delay: 180ms;
|
||
}
|
||
|
||
.bento > :nth-child(6) {
|
||
animation-delay: 225ms;
|
||
}
|
||
|
||
.bento > :nth-child(7) {
|
||
animation-delay: 270ms;
|
||
}
|
||
|
||
.bento > :nth-child(8) {
|
||
animation-delay: 315ms;
|
||
}
|
||
|
||
.bento-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--ys-shadow-raise);
|
||
}
|
||
|
||
.b-2x2 {
|
||
grid-column: span 2;
|
||
grid-row: span 2;
|
||
}
|
||
|
||
.b-full {
|
||
grid-column: span 4;
|
||
}
|
||
|
||
.b-eyebrow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
letter-spacing: 0.04em;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.b-eyebrow-gap {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.b-count {
|
||
min-width: 20px;
|
||
padding: 0 6px;
|
||
font-size: 11px;
|
||
line-height: 18px;
|
||
text-align: center;
|
||
color: var(--ys-indigo);
|
||
background: var(--ys-indigo-wash);
|
||
border-radius: 9px;
|
||
}
|
||
|
||
.b-num {
|
||
margin-top: 10px;
|
||
font-size: 26px;
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.b-num small {
|
||
margin-left: 4px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.b-sub {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.b-empty-mini {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.chart {
|
||
height: 150px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.todo-list,
|
||
.warning-list {
|
||
max-height: 178px;
|
||
margin-top: 8px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.todo-row,
|
||
.warning-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
width: 100%;
|
||
padding: 6px 4px;
|
||
border: none;
|
||
border-bottom: 1px dashed var(--ys-line);
|
||
background: none;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.todo-row:hover,
|
||
.warning-row:hover {
|
||
background: rgba(46, 75, 168, 0.04);
|
||
}
|
||
|
||
.todo-title {
|
||
font-size: 13px;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.todo-meta {
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.warning-row {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
grid-template-rows: auto auto;
|
||
column-gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.warning-title {
|
||
font-size: 13px;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.warning-detail {
|
||
grid-column: 2;
|
||
font-size: 11px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.activity-list {
|
||
max-height: 150px;
|
||
margin-top: 8px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.activity-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 5px 0;
|
||
border-bottom: 1px dashed var(--ys-line);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.activity-time {
|
||
width: 108px;
|
||
color: var(--ys-ink-3);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.activity-summary {
|
||
flex: 1;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.activity-user {
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.b-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.b-op {
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
color: var(--ys-indigo);
|
||
cursor: pointer;
|
||
background: var(--ys-indigo-wash);
|
||
border: 1px solid transparent;
|
||
border-radius: var(--ys-radius-sm);
|
||
transition:
|
||
border-color 150ms var(--ys-ease),
|
||
background-color 150ms var(--ys-ease);
|
||
}
|
||
|
||
.b-op:hover {
|
||
background: #e6ecf9;
|
||
border-color: rgba(46, 75, 168, 0.35);
|
||
}
|
||
|
||
.b-op:focus-visible {
|
||
outline: 2px solid var(--ys-indigo);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.bento {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.b-full {
|
||
grid-column: span 2;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.bento {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.b-2x2,
|
||
.b-full {
|
||
grid-column: span 1;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.bento-card {
|
||
transition: none;
|
||
}
|
||
|
||
.bento-card:hover {
|
||
transform: none;
|
||
}
|
||
}
|
||
</style>
|