feat:新增数据看板

This commit is contained in:
zheng020 2026-06-03 01:20:37 +08:00
parent 20f86ceec0
commit 463e6cc008
11 changed files with 190 additions and 83 deletions

View File

@ -1,5 +1,20 @@
import { ref, computed } from 'vue'
import { dashboardApi } from '@/utils/api'
import * as mock from '@/utils/mock/dashboard'
// USE_MOCK_API 是写死常量——直接内联短路逻辑,绕开 api.js 的 dashboardRequest 链路
// 原因:标准基座 + Vite HMR 在 App 端会让 api.js 内部的 async 包装 + signal 透传
// 导致 useDashboardData.loadSection 里的 await 永远不 resolve。
// 直接调 mock 工厂200-600ms 内必定 resolve。
const MOCK_FACTORIES = {
today: mock.mockTodayOverview,
curve: mock.mock7DayIncomeCurve,
exhibition: mock.mockExhibitionSummary,
likeIncome: mock.mockLikeIncomeByLevel,
topAssets: mock.mockTopAssets,
levels: mock.mockLevelDistribution,
upgrades: mock.mockUpgradeProgress,
}
// section 名 → fetcher 映射模块级单例loadAll 与 refresh 复用
const SECTION_FETCHERS = {
@ -60,12 +75,17 @@ export function useDashboardData({ starId = null } = {}) {
Object.values(data.value).every((v) => v !== null && v !== undefined)
)
// 内部辅助:单 section 加载data 解包在 boundary 处进行
// 内部辅助:单 section 加载
async function loadSection(section, fetcher) {
loading.value[section] = true
error.value[section] = null
try {
const result = await fetcher(starId)
// USE_MOCK_API 走 mock直接调本地工厂绕开 api.js 的 dashboardRequest 包装
// 解决标准基座 + Vite HMR 下 await fetcher(starId, { signal }) 永远不 resolve 的问题
const mockFactory = MOCK_FACTORIES[section]
const result = mockFactory
? await mockFactory({ star_id: starId })
: await fetcher(starId)
data.value[section] = result?.data ?? result
} catch (e) {
error.value[section] = e?.message || '加载失败'
@ -91,7 +111,7 @@ export function useDashboardData({ starId = null } = {}) {
}
}
// 局部刷新refresh('curve') 只刷一个refresh() 全量 cache-awarerefresh(true) 全量强制
// 局部刷新refresh('curve') 只刷一个refresh() 全量 cache-awarerefresh(null, true) 全量强制
async function refresh(section, force = false) {
if (section) {
const fetcher = SECTION_FETCHERS[section]
@ -101,10 +121,7 @@ export function useDashboardData({ starId = null } = {}) {
return loadAll(force)
}
function dispose() {
// ref 状态由 Vue 自动 GC保留接口给未来清理如取消 in-flight 请求)
}
// 初次加载
loadAll()
return {
@ -114,6 +131,5 @@ export function useDashboardData({ starId = null } = {}) {
refresh,
isReady,
lastFetched,
dispose,
}
}

View File

@ -1,4 +1,10 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^qiun-(.*)": "@/uni_modules/qiun-data-charts/components/qiun-$1/qiun-$1.vue"
}
},
"pages": [{
"path": "pages/square/square",
"style": {

View File

@ -360,6 +360,13 @@ const handleBack = () => {
}
}
};
//
const handleClick = () => {
uni.navigateTo({
url: "/pages/dashboard/dashboard",
});
};
//
const handleTaskClick = () => {
showTaskModal.value = true;

View File

@ -62,12 +62,12 @@ const toggleDrawer = () => {
const cards = [
{
label: '限时上新',
image: '/static/starcity/tongkuan/29dcf35c07c836ad83d92bee0cc4a0af.png',
image: '/static/starcity/tongkuan/1.jpg',
isNew: true
},
{
label: '限时上新',
image: '/static/starcity/tongkuan/38202ae3fe02ac1bee1078a6049d0829.png',
image: '/static/starcity/tongkuan/2.jpg',
isNew: false
},
{

View File

@ -12,34 +12,42 @@
<!-- 图表 -->
<view v-else class="chart-wrap">
<view class="chart-header">
<text class="chart-peak-value" v-if="peak">+ {{ peak.income }}</text>
<text class="chart-peak-date" v-if="peak">{{ peak.date }}</text>
</view>
<view class="chart-canvas">
<!-- #ifdef H5 -->
<qiun-data-charts
type="barline"
type="area"
:opts="chartOpts"
:chartData="chartData"
:ontouch="true"
:onmovetip="true"
canvas2d
:ontap="handleChartTap"
ontap
@complete="handleChartComplete"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS || MP-WEIXIN -->
<view class="chart-placeholder-fallback">
<text>📊 {{ points.length }} 天数据</text>
<text class="chart-fallback-sub">App 端图表组件配置中</text>
<!-- 默认指向最后一天的指示器位置来自 uCharts calPoints跟图表完全对齐 -->
<view
v-if="latestPoint && latestCalPoint"
class="latest-indicator"
:style="{
left: latestCalPoint.x / pixel + 'px',
top: latestCalPoint.y / pixel + 'px',
}"
>
<view class="latest-line"></view>
<view class="latest-dot"></view>
<view class="latest-tooltip">
<text class="latest-value">+{{ latestPoint.income }}</text>
<text class="latest-date">{{ latestPoint.date.slice(5) }}</text>
</view>
</view>
<!-- #endif -->
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
// import easycom
import QiunDataCharts from '@/uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue'
const props = defineProps({
points: { type: Array, default: () => [] },
@ -48,40 +56,81 @@ const props = defineProps({
})
defineEmits(['retry'])
const peak = computed(() => props.points.find((p) => p.is_peak) || null)
// canvas2d opts.pix=1 canvas2d opts.pix=systemInfo.pixelRatio
// emit opts 1
const pixel = ref(1)
const chartOptsRef = ref(null)
const calPoints = ref([])
let chartData = null
let chartOpts = null
// #ifdef H5
chartData = computed(() => {
// @completeuCharts emit opts.chartData.calPoints canvas
function handleChartComplete(e) {
const opts = e?.opts
if (!opts) return
chartOptsRef.value = opts
pixel.value = opts.pix || 1
calPoints.value = opts.chartData?.calPoints || []
}
// chartOpts.padding / chart-canvas rpx
const CHART_HEIGHT = 240
const PADDING = [16, 16, 8, 16] // top, right, bottom, left
const peak = computed(() => props.points.find((p) => p.is_peak) || null)
const latestPoint = computed(() =>
props.points.length > 0 ? props.points[props.points.length - 1] : null
)
// canvas uCharts @complete calPoints
// calPoints calPoints[seriesIndex][dataIndex] = { x, y }y canvas
const latestCalPoint = computed(() => {
if (!latestPoint.value || calPoints.value.length === 0) return null
const seriesPoints = calPoints.value[0]
if (!seriesPoints || seriesPoints.length === 0) return null
return seriesPoints[seriesPoints.length - 1] || null
})
const chartData = computed(() => {
const categories = props.points.map((p) => p.date.slice(5))
const barData = props.points.map((p) => p.income)
const lineData = props.points.map((p) => p.income)
return {
categories,
series: [
{ name: '收益', data: barData, color: '#FFDF77' },
{ name: '趋势', data: lineData, color: '#1BAFEE', type: 'line' },
{ name: '收益', type: 'area', data: lineData, color: '#1BAFEE' },
],
}
})
chartOpts = {
color: ['#FFCC14', '#1BAFEE'],
padding: [16, 16, 8, 16],
const chartOpts = {
color: ['#1BAFEE'],
padding: PADDING,
dataLabel: false,
legend: { show: false },
xAxis: { disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
yAxis: { data: [{ min: 0 }], disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
extra: {
bar: { type: 'group', width: 18, linear: true, color: ['#FFDF77', '#B984FF', '#FF8183'] },
line: { type: 'curve', width: 2, activeType: 'hollow' },
// 线
dataPointShape: false,
xAxis: { disabled: true, disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
yAxis: {
disabled: true,
showTitle: false,
data: [{ min: 0, disabled: true, axisLine: false }],
disableGrid: true,
fontColor: '#FFFFFF',
fontSize: 9,
},
extra: {
tooltip: {
showBox: true,
showArrow: true,
showCategory: true,
bgColor: '#000000',
bgOpacity: 0.6,
fontColor: '#FFFFFF',
fontSize: 11,
splitLine: true,
horizentalLine: { type: 'dash', width: 1, color: '#FFFFFF' },
},
// area 线gradient: true 线
area: { type: 'curve', opacity: 0.3, addLine: true, width: 2, gradient: true, activeType: 'hollow' },
},
}
// #endif
function handleChartTap(e) {
console.log('[IncomeCurve] chart tap', e)
}
</script>
@ -111,45 +160,63 @@ function handleChartTap(e) {
backdrop-filter: blur(10px);
}
.chart-header {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-bottom: 12rpx;
}
.chart-peak-value {
font-size: 40rpx;
font-weight: 700;
color: #FFFABD;
text-shadow: -1px 1px 4px rgba(206, 9, 9, 0.84);
font-family: 'Baloo Bhai', sans-serif;
}
.chart-peak-date {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.8);
}
.chart-canvas {
position: relative;
width: 100%;
height: 240rpx;
}
.chart-placeholder-fallback {
height: 240rpx;
.latest-indicator {
position: absolute;
z-index: 2;
// left/top translate(-50%, -100%) tooltip indicator
transform: translate(-50%, -100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28rpx;
pointer-events: none;
}
.chart-fallback-sub {
margin-top: 8rpx;
font-size: 22rpx;
opacity: 0.7;
.latest-line {
width: 1rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.7);
margin-bottom: -2rpx;
align-self: center;
}
.latest-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #1BAFEE;
border: 2rpx solid #ffffff;
align-self: center;
margin-bottom: 6rpx;
}
.latest-tooltip {
background: rgba(0, 0, 0, 0.6);
border-radius: 8rpx;
padding: 6rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 4rpx;
}
.latest-value {
font-size: 24rpx;
font-weight: 700;
color: #FFFABD;
line-height: 1.2;
}
.latest-date {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.85);
line-height: 1.2;
margin-top: 2rpx;
}
.skeleton-chart {

View File

@ -59,7 +59,7 @@
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import DashboardHeader from './components/DashboardHeader.vue'
import CrystalOverview from './components/CrystalOverview.vue'
@ -71,8 +71,9 @@ import { useDashboardData } from '@/composables/useDashboardData'
const activeTab = ref('crystal')
const starId = ref(uni.getStorageSync('star_id') || null)
const isFirstShow = ref(true)
const { loading, error, data, refresh, isReady, lastFetched, dispose } = useDashboardData({
const { loading, error, data, refresh } = useDashboardData({
starId: starId.value,
})
@ -91,19 +92,21 @@ async function handlePullDownRefresh() {
uni.stopPullDownRefresh()
}
onUnmounted(() => {
dispose()
})
// 30 spec §4.1
// onShow composable loadAll() 14
onShow(() => {
if (isFirstShow.value) {
isFirstShow.value = false
return
}
refresh(null, true)
})
</script>
<style lang="scss" scoped>
@import '@/uni.scss';
.dashboard-page-bg {
background: linear-gradient(153deg, #FF9597 0%, #80DFFF 33%, #B8B8B8 74%, #D9D9D9 100%);
background: $d-page-bg;
min-height: 100vh;
}
.dashboard-scroll {

View File

@ -291,7 +291,7 @@ const mainTabs = ref([
// ========== ==========
const categories = ref([
{ label: "热门作品", value: "hot" },
{ label: "最新作品", value: "latest" },
{ label: "最新作品", value: "new" },
{ label: "星卡", value: "star_card" },
{ label: "吧唧", value: "badge" },
{ label: "海报", value: "poster" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -161,6 +161,9 @@ import cfu from '../../js_sdk/u-charts/config-ucharts.js';
// #ifdef APP-VUE || H5
import cfe from '../../js_sdk/u-charts/config-echarts.js';
// #endif
// import easycom / dev server
import QiunError from '../qiun-error/qiun-error.vue';
import QiunLoading from '../qiun-loading/qiun-loading.vue';
function deepCloneAssign(origin = {}, ...args) {
for (let i in args) {
@ -232,6 +235,11 @@ function debounce(fn, wait) {
export default {
name: 'qiun-data-charts',
mixins: [uniCloud.mixinDatacom],
// easycom / dev server
components: {
QiunError,
QiunLoading,
},
props: {
type: {
type: String,

View File

@ -2950,7 +2950,7 @@ function drawToolTip(textList, offset, opts, config, context, eachSpacing, xAxis
var startY = offset.y + lineHeight * index + (lineHeight - fontSize)/2 - 1 + boxPadding + fontSize;
context.beginPath();
context.setFontSize(fontSize);
context.setTextBaseline('normal');
context.setTextBaseline('alphabetic');
context.setFillStyle(toolTipOption.fontColor);
context.fillText(item.text, startX, startY);
context.closePath();