feat(浠〃鐩?: 鏂板4涓狤Charts鍙鍖栧浘琛?
- 杩?0澶╀骇鍑鸿秼鍔挎姌绾垮浘 - 鎴愭湰鏋勬垚鐜舰楗煎浘锛堜汉鍔?AI宸ュ叿/澶栧寘锛?- 椤圭洰浜у嚭瀵规瘮妯悜鏌辩姸鍥?- 鎹熻€楁帓琛屾煴鐘跺浘锛堟寜鎹熻€楃巼鐫€鑹诧級 - 鍚庣鏂板 daily_trend / cost_breakdown / project_comparison 鏁版嵁鎺ュ彛 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bc06725ed1
commit
9970446ece
@ -116,6 +116,47 @@ def get_dashboard(
|
|||||||
"profit_loss": settlement.get("profit_loss"),
|
"profit_loss": settlement.get("profit_loss"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ── 图表数据:近30天每日产出趋势 ──
|
||||||
|
daily_trend = []
|
||||||
|
for i in range(29, -1, -1):
|
||||||
|
d = today - timedelta(days=i)
|
||||||
|
day_secs = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.submit_date == d,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
).scalar() or 0
|
||||||
|
daily_trend.append({
|
||||||
|
"date": str(d),
|
||||||
|
"seconds": round(day_secs, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 图表数据:成本构成 ──
|
||||||
|
total_labor_all = 0.0
|
||||||
|
total_ai_all = 0.0
|
||||||
|
total_outsource_all = 0.0
|
||||||
|
for p in active + completed:
|
||||||
|
total_labor_all += calc_labor_cost_for_project(p.id, db)
|
||||||
|
total_ai_all += calc_ai_tool_cost_for_project(p.id, db)
|
||||||
|
total_outsource_all += calc_outsource_cost_for_project(p.id, db)
|
||||||
|
|
||||||
|
cost_breakdown = [
|
||||||
|
{"name": "人力成本", "value": round(total_labor_all, 0)},
|
||||||
|
{"name": "AI工具", "value": round(total_ai_all, 0)},
|
||||||
|
{"name": "外包", "value": round(total_outsource_all, 0)},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 图表数据:各项目产出对比(进行中项目) ──
|
||||||
|
project_comparison = []
|
||||||
|
for p in active:
|
||||||
|
total_secs_p = db.query(sa_func.sum(Submission.total_seconds)).filter(
|
||||||
|
Submission.project_id == p.id,
|
||||||
|
Submission.total_seconds > 0,
|
||||||
|
).scalar() or 0
|
||||||
|
project_comparison.append({
|
||||||
|
"name": p.name,
|
||||||
|
"submitted": round(total_secs_p, 0),
|
||||||
|
"target": p.target_total_seconds,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"active_projects": len(active),
|
"active_projects": len(active),
|
||||||
"completed_projects": len(completed),
|
"completed_projects": len(completed),
|
||||||
@ -126,6 +167,10 @@ def get_dashboard(
|
|||||||
"projects": project_summaries,
|
"projects": project_summaries,
|
||||||
"waste_ranking": waste_ranking,
|
"waste_ranking": waste_ranking,
|
||||||
"settled_projects": settled,
|
"settled_projects": settled,
|
||||||
|
# 图表数据
|
||||||
|
"daily_trend": daily_trend,
|
||||||
|
"cost_breakdown": cost_breakdown,
|
||||||
|
"project_comparison": project_comparison,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表行:产出趋势 + 成本构成 -->
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="card chart-card wide">
|
||||||
|
<div class="card-header"><span class="card-title">近 30 天产出趋势</span></div>
|
||||||
|
<div class="card-body"><div ref="trendChartRef" class="chart-container"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card chart-card narrow">
|
||||||
|
<div class="card-header"><span class="card-title">成本构成</span></div>
|
||||||
|
<div class="card-body"><div ref="costChartRef" class="chart-container"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 项目进度 -->
|
<!-- 项目进度 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -48,12 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="progress-pct">{{ p.progress_percent }}%</span>
|
<span class="progress-pct">{{ p.progress_percent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<el-progress
|
<el-progress :percentage="Math.min(p.progress_percent, 100)" :color="p.is_overdue ? '#FF3B30' : '#3370FF'" :stroke-width="6" :show-text="false" />
|
||||||
:percentage="Math.min(p.progress_percent, 100)"
|
|
||||||
:color="p.is_overdue ? '#FF3B30' : '#3370FF'"
|
|
||||||
:stroke-width="6"
|
|
||||||
:show-text="false"
|
|
||||||
/>
|
|
||||||
<div class="progress-meta">
|
<div class="progress-meta">
|
||||||
<span>{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
|
<span>{{ formatSecs(p.submitted_seconds) }} / {{ formatSecs(p.target_seconds) }}</span>
|
||||||
<span v-if="p.waste_rate > 0" :style="{color: p.waste_rate > 30 ? '#FF3B30' : '#8F959E'}">损耗 {{ p.waste_rate }}%</span>
|
<span v-if="p.waste_rate > 0" :style="{color: p.waste_rate > 30 ? '#FF3B30' : '#8F959E'}">损耗 {{ p.waste_rate }}%</span>
|
||||||
@ -63,59 +70,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two-col">
|
<!-- 图表行:项目产出对比 + 损耗排行 -->
|
||||||
<!-- 损耗排行 -->
|
<div class="chart-row">
|
||||||
<div class="card">
|
<div class="card chart-card half">
|
||||||
<div class="card-header"><span class="card-title">损耗排行</span></div>
|
<div class="card-header"><span class="card-title">项目产出对比</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body"><div ref="comparisonChartRef" class="chart-container"></div></div>
|
||||||
<el-table :data="data.waste_ranking" size="small">
|
|
||||||
<el-table-column prop="project_name" label="项目" />
|
|
||||||
<el-table-column label="损耗" align="right" width="100">
|
|
||||||
<template #default="{ row }">{{ formatSecs(row.waste_seconds) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="损耗率" align="right" width="90">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span class="rate-badge" :class="{ danger: row.waste_rate > 30 }">{{ row.waste_rate }}%</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-empty v-if="!data.waste_ranking?.length" description="暂无数据" :image-size="60" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card chart-card half">
|
||||||
|
<div class="card-header"><span class="card-title">损耗排行</span></div>
|
||||||
|
<div class="card-body"><div ref="wasteChartRef" class="chart-container"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 已结算项目 -->
|
<!-- 已结算项目 -->
|
||||||
<div class="card">
|
<div class="card" v-if="data.settled_projects?.length">
|
||||||
<div class="card-header"><span class="card-title">已结算项目</span></div>
|
<div class="card-header"><span class="card-title">已结算项目</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<el-table :data="data.settled_projects" size="small">
|
<el-table :data="data.settled_projects" size="small">
|
||||||
<el-table-column prop="project_name" label="项目" />
|
<el-table-column prop="project_name" label="项目" />
|
||||||
<el-table-column label="总成本" align="right" width="100">
|
<el-table-column label="总成本" align="right" width="120">
|
||||||
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
<template #default="{ row }">¥{{ formatNum(row.total_cost) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="盈亏" align="right" width="100">
|
<el-table-column label="盈亏" align="right" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
|
<span v-if="row.profit_loss != null" class="profit" :class="{ loss: row.profit_loss < 0 }">
|
||||||
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
{{ row.profit_loss >= 0 ? '+' : '' }}¥{{ formatNum(row.profit_loss) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-muted">—</span>
|
<span v-else class="text-muted">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-empty v-if="!data.settled_projects?.length" description="暂无数据" :image-size="60" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { dashboardApi } from '../api'
|
import { dashboardApi } from '../api'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const data = ref({})
|
const data = ref({})
|
||||||
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
const typeTagMap = { '客户正式项目': 'success', '客户测试项目': 'warning', '内部原创项目': '', '内部测试项目': 'info' }
|
||||||
|
|
||||||
|
const trendChartRef = ref(null)
|
||||||
|
const costChartRef = ref(null)
|
||||||
|
const comparisonChartRef = ref(null)
|
||||||
|
const wasteChartRef = ref(null)
|
||||||
|
|
||||||
|
let trendChart, costChart, comparisonChart, wasteChart
|
||||||
|
|
||||||
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
function formatNum(n) { return (n || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 }) }
|
||||||
function formatSecs(s) {
|
function formatSecs(s) {
|
||||||
if (!s) return '0秒'
|
if (!s) return '0秒'
|
||||||
@ -124,35 +129,142 @@ function formatSecs(s) {
|
|||||||
return m > 0 ? `${m}分${sec > 0 ? sec + '秒' : ''}` : `${sec}秒`
|
return m > 0 ? `${m}分${sec > 0 ? sec + '秒' : ''}` : `${sec}秒`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 图表公共配置 ──
|
||||||
|
const chartFont = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||||
|
const gridBase = { left: 48, right: 16, top: 24, bottom: 32 }
|
||||||
|
|
||||||
|
function initTrendChart(trend) {
|
||||||
|
if (!trendChartRef.value || !trend?.length) return
|
||||||
|
trendChart = echarts.init(trendChartRef.value)
|
||||||
|
trendChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', formatter: p => `${p[0].axisValue}<br/>产出 <b>${formatSecs(p[0].value)}</b>` },
|
||||||
|
grid: gridBase,
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: trend.map(d => d.date.slice(5)),
|
||||||
|
axisLabel: { fontSize: 11, color: '#8F959E' },
|
||||||
|
axisLine: { lineStyle: { color: '#E5E6EB' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 60 ? Math.floor(v/60) + 'm' : v + 's' },
|
||||||
|
splitLine: { lineStyle: { color: '#F0F1F2' } },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'line',
|
||||||
|
data: trend.map(d => d.seconds),
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
lineStyle: { width: 2.5, color: '#3370FF' },
|
||||||
|
itemStyle: { color: '#3370FF' },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(51,112,255,0.15)' },
|
||||||
|
{ offset: 1, color: 'rgba(51,112,255,0.01)' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCostChart(breakdown) {
|
||||||
|
if (!costChartRef.value || !breakdown?.length) return
|
||||||
|
const total = breakdown.reduce((s, b) => s + b.value, 0)
|
||||||
|
costChart = echarts.init(costChartRef.value)
|
||||||
|
costChart.setOption({
|
||||||
|
tooltip: { formatter: p => `${p.name}<br/>¥${p.value.toLocaleString()} (${((p.value/Math.max(total,1))*100).toFixed(1)}%)` },
|
||||||
|
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#8F959E' } },
|
||||||
|
color: ['#3370FF', '#FF9500', '#34C759'],
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['48%', '72%'],
|
||||||
|
center: ['50%', '45%'],
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, fontSize: 13, fontWeight: 600 } },
|
||||||
|
data: breakdown.filter(b => b.value > 0),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initComparisonChart(comparison) {
|
||||||
|
if (!comparisonChartRef.value || !comparison?.length) return
|
||||||
|
comparisonChart = echarts.init(comparisonChartRef.value)
|
||||||
|
const names = comparison.map(c => c.name.length > 8 ? c.name.slice(0,8) + '…' : c.name)
|
||||||
|
comparisonChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', formatter: p => p.map(i => `${i.seriesName}: ${formatSecs(i.value)}`).join('<br/>') },
|
||||||
|
legend: { bottom: 0, textStyle: { fontSize: 12, color: '#8F959E' } },
|
||||||
|
grid: { left: 12, right: 16, top: 16, bottom: 36, containLabel: true },
|
||||||
|
xAxis: { type: 'value', axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v >= 60 ? Math.floor(v/60) + 'm' : v + 's' }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
||||||
|
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
||||||
|
color: ['#3370FF', '#E5E6EB'],
|
||||||
|
series: [
|
||||||
|
{ name: '已提交', type: 'bar', data: comparison.map(c => c.submitted), barWidth: 14, borderRadius: [0, 4, 4, 0], itemStyle: { color: '#3370FF' } },
|
||||||
|
{ name: '目标', type: 'bar', data: comparison.map(c => c.target), barWidth: 14, borderRadius: [0, 4, 4, 0], itemStyle: { color: '#E5E6EB' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWasteChart(ranking) {
|
||||||
|
if (!wasteChartRef.value || !ranking?.length) return
|
||||||
|
wasteChart = echarts.init(wasteChartRef.value)
|
||||||
|
const sorted = [...ranking].sort((a, b) => a.waste_rate - b.waste_rate)
|
||||||
|
const names = sorted.map(r => r.project_name.length > 8 ? r.project_name.slice(0,8) + '…' : r.project_name)
|
||||||
|
wasteChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis', formatter: p => `${p[0].name}<br/>损耗率 <b>${p[0].value}%</b>` },
|
||||||
|
grid: { left: 12, right: 24, top: 16, bottom: 16, containLabel: true },
|
||||||
|
xAxis: { type: 'value', max: v => Math.max(v.max * 1.2, 10), axisLabel: { fontSize: 11, color: '#8F959E', formatter: v => v + '%' }, splitLine: { lineStyle: { color: '#F0F1F2' } } },
|
||||||
|
yAxis: { type: 'category', data: names, axisLabel: { fontSize: 12, color: '#3B3F46' }, axisLine: { show: false }, axisTick: { show: false } },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', data: sorted.map(r => ({
|
||||||
|
value: r.waste_rate,
|
||||||
|
itemStyle: { color: r.waste_rate > 30 ? '#FF3B30' : r.waste_rate > 15 ? '#FF9500' : '#3370FF', borderRadius: [0, 4, 4, 0] },
|
||||||
|
})),
|
||||||
|
barWidth: 14,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
trendChart?.resize()
|
||||||
|
costChart?.resize()
|
||||||
|
comparisonChart?.resize()
|
||||||
|
wasteChart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try { data.value = await dashboardApi.get() } finally { loading.value = false }
|
try {
|
||||||
|
data.value = await dashboardApi.get()
|
||||||
|
await nextTick()
|
||||||
|
initTrendChart(data.value.daily_trend)
|
||||||
|
initCostChart(data.value.cost_breakdown)
|
||||||
|
initComparisonChart(data.value.project_comparison)
|
||||||
|
initWasteChart(data.value.waste_ranking)
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
trendChart?.dispose()
|
||||||
|
costChart?.dispose()
|
||||||
|
comparisonChart?.dispose()
|
||||||
|
wasteChart?.dispose()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 统计网格 */
|
/* 统计网格 */
|
||||||
.stat-grid {
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
border: 1px solid var(--border-color);
|
border-radius: var(--radius-md); padding: 20px; display: flex; align-items: center; gap: 16px;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.stat-icon {
|
|
||||||
width: 44px; height: 44px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
.stat-icon { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
.stat-icon.blue { background: #E8F0FE; color: #3370FF; }
|
.stat-icon.blue { background: #E8F0FE; color: #3370FF; }
|
||||||
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
.stat-icon.orange { background: #FFF3E0; color: #FF9500; }
|
||||||
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
.stat-icon.green { background: #E8F8EE; color: #34C759; }
|
||||||
@ -161,19 +273,22 @@ onMounted(async () => {
|
|||||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
||||||
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
.stat-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* 图表布局 */
|
||||||
|
.chart-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.chart-card { flex: 1; }
|
||||||
|
.chart-card.wide { flex: 2; }
|
||||||
|
.chart-card.narrow { flex: 1; }
|
||||||
|
.chart-card.half { flex: 1; }
|
||||||
|
.chart-container { width: 100%; height: 260px; }
|
||||||
|
|
||||||
/* 卡片 */
|
/* 卡片 */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card); border: 1px solid var(--border-color);
|
||||||
border: 1px solid var(--border-color);
|
border-radius: var(--radius-md); margin-bottom: 16px;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
|
||||||
border-bottom: 1px solid var(--border-light);
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||||
.card-count { font-size: 12px; color: var(--text-secondary); }
|
.card-count { font-size: 12px; color: var(--text-secondary); }
|
||||||
@ -181,13 +296,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
/* 项目进度 */
|
/* 项目进度 */
|
||||||
.progress-item {
|
.progress-item {
|
||||||
padding: 14px 0;
|
padding: 14px 0; border-bottom: 1px solid var(--border-light); cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-light);
|
transition: background 0.15s; margin: 0 -20px; padding-left: 20px; padding-right: 20px;
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
margin: 0 -20px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
}
|
||||||
.progress-item:last-child { border-bottom: none; }
|
.progress-item:last-child { border-bottom: none; }
|
||||||
.progress-item:hover { background: #FAFBFC; }
|
.progress-item:hover { background: #FAFBFC; }
|
||||||
@ -195,20 +305,8 @@ onMounted(async () => {
|
|||||||
.progress-info { display: flex; align-items: center; gap: 8px; }
|
.progress-info { display: flex; align-items: center; gap: 8px; }
|
||||||
.progress-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
.progress-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||||
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
.progress-pct { font-size: 14px; font-weight: 600; color: var(--primary); }
|
||||||
.progress-meta {
|
.progress-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
font-size: 12px; color: var(--text-secondary); margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 两列布局 */
|
|
||||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
|
|
||||||
/* 标签 */
|
|
||||||
.rate-badge {
|
|
||||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
|
||||||
background: var(--bg-hover); padding: 2px 8px; border-radius: 4px;
|
|
||||||
}
|
|
||||||
.rate-badge.danger { background: #FFE8E7; color: #FF3B30; }
|
|
||||||
.profit { font-weight: 600; color: #34C759; }
|
.profit { font-weight: 600; color: #34C759; }
|
||||||
.profit.loss { color: #FF3B30; }
|
.profit.loss { color: #FF3B30; }
|
||||||
.text-muted { color: var(--text-secondary); }
|
.text-muted { color: var(--text-secondary); }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user