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 { ref, computed } from 'vue'
import { dashboardApi } from '@/utils/api' 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 复用 // section 名 → fetcher 映射模块级单例loadAll 与 refresh 复用
const SECTION_FETCHERS = { const SECTION_FETCHERS = {
@ -60,12 +75,17 @@ export function useDashboardData({ starId = null } = {}) {
Object.values(data.value).every((v) => v !== null && v !== undefined) Object.values(data.value).every((v) => v !== null && v !== undefined)
) )
// 内部辅助:单 section 加载data 解包在 boundary 处进行 // 内部辅助:单 section 加载
async function loadSection(section, fetcher) { async function loadSection(section, fetcher) {
loading.value[section] = true loading.value[section] = true
error.value[section] = null error.value[section] = null
try { 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 data.value[section] = result?.data ?? result
} catch (e) { } catch (e) {
error.value[section] = e?.message || '加载失败' 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) { async function refresh(section, force = false) {
if (section) { if (section) {
const fetcher = SECTION_FETCHERS[section] const fetcher = SECTION_FETCHERS[section]
@ -101,10 +121,7 @@ export function useDashboardData({ starId = null } = {}) {
return loadAll(force) return loadAll(force)
} }
function dispose() { // 初次加载
// ref 状态由 Vue 自动 GC保留接口给未来清理如取消 in-flight 请求)
}
loadAll() loadAll()
return { return {
@ -114,6 +131,5 @@ export function useDashboardData({ starId = null } = {}) {
refresh, refresh,
isReady, isReady,
lastFetched, 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": [{ "pages": [{
"path": "pages/square/square", "path": "pages/square/square",
"style": { "style": {

View File

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

View File

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

View File

@ -12,34 +12,42 @@
<!-- 图表 --> <!-- 图表 -->
<view v-else class="chart-wrap"> <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"> <view class="chart-canvas">
<!-- #ifdef H5 -->
<qiun-data-charts <qiun-data-charts
type="barline" type="area"
:opts="chartOpts" :opts="chartOpts"
:chartData="chartData" :chartData="chartData"
:ontouch="true" :ontouch="true"
:onmovetip="true"
canvas2d canvas2d
:ontap="handleChartTap" ontap
@complete="handleChartComplete"
/> />
<!-- #endif --> <!-- 默认指向最后一天的指示器位置来自 uCharts calPoints跟图表完全对齐 -->
<!-- #ifdef APP-PLUS || MP-WEIXIN --> <view
<view class="chart-placeholder-fallback"> v-if="latestPoint && latestCalPoint"
<text>📊 {{ points.length }} 天数据</text> class="latest-indicator"
<text class="chart-fallback-sub">App 端图表组件配置中</text> :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> </view>
<!-- #endif -->
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <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({ const props = defineProps({
points: { type: Array, default: () => [] }, points: { type: Array, default: () => [] },
@ -48,40 +56,81 @@ const props = defineProps({
}) })
defineEmits(['retry']) 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 // @completeuCharts emit opts.chartData.calPoints canvas
let chartOpts = null function handleChartComplete(e) {
// #ifdef H5 const opts = e?.opts
chartData = computed(() => { 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 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) const lineData = props.points.map((p) => p.income)
return { return {
categories, categories,
series: [ series: [
{ name: '收益', data: barData, color: '#FFDF77' }, { name: '收益', type: 'area', data: lineData, color: '#1BAFEE' },
{ name: '趋势', data: lineData, color: '#1BAFEE', type: 'line' },
], ],
} }
}) })
chartOpts = { const chartOpts = {
color: ['#FFCC14', '#1BAFEE'], color: ['#1BAFEE'],
padding: [16, 16, 8, 16], padding: PADDING,
dataLabel: false, dataLabel: false,
legend: { show: 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 }, dataPointShape: false,
extra: { xAxis: { disabled: true, disableGrid: true, axisLine: false, fontColor: '#FFFFFF', fontSize: 9 },
bar: { type: 'group', width: 18, linear: true, color: ['#FFDF77', '#B984FF', '#FF8183'] }, yAxis: {
line: { type: 'curve', width: 2, activeType: 'hollow' }, 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> </script>
@ -111,45 +160,63 @@ function handleChartTap(e) {
backdrop-filter: blur(10px); 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 { .chart-canvas {
position: relative;
width: 100%; width: 100%;
height: 240rpx; height: 240rpx;
} }
.chart-placeholder-fallback { .latest-indicator {
height: 240rpx; position: absolute;
z-index: 2;
// left/top translate(-50%, -100%) tooltip indicator
transform: translate(-50%, -100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; pointer-events: none;
color: #ffffff;
font-size: 28rpx;
} }
.chart-fallback-sub { .latest-line {
margin-top: 8rpx; width: 1rpx;
font-size: 22rpx; height: 60rpx;
opacity: 0.7; 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 { .skeleton-chart {

View File

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

View File

@ -291,7 +291,7 @@ const mainTabs = ref([
// ========== ========== // ========== ==========
const categories = ref([ const categories = ref([
{ label: "热门作品", value: "hot" }, { label: "热门作品", value: "hot" },
{ label: "最新作品", value: "latest" }, { label: "最新作品", value: "new" },
{ label: "星卡", value: "star_card" }, { label: "星卡", value: "star_card" },
{ label: "吧唧", value: "badge" }, { label: "吧唧", value: "badge" },
{ label: "海报", value: "poster" }, { 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 // #ifdef APP-VUE || H5
import cfe from '../../js_sdk/u-charts/config-echarts.js'; import cfe from '../../js_sdk/u-charts/config-echarts.js';
// #endif // #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) { function deepCloneAssign(origin = {}, ...args) {
for (let i in args) { for (let i in args) {
@ -232,6 +235,11 @@ function debounce(fn, wait) {
export default { export default {
name: 'qiun-data-charts', name: 'qiun-data-charts',
mixins: [uniCloud.mixinDatacom], mixins: [uniCloud.mixinDatacom],
// easycom / dev server
components: {
QiunError,
QiunLoading,
},
props: { props: {
type: { type: {
type: String, 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; var startY = offset.y + lineHeight * index + (lineHeight - fontSize)/2 - 1 + boxPadding + fontSize;
context.beginPath(); context.beginPath();
context.setFontSize(fontSize); context.setFontSize(fontSize);
context.setTextBaseline('normal'); context.setTextBaseline('alphabetic');
context.setFillStyle(toolTipOption.fontColor); context.setFillStyle(toolTipOption.fontColor);
context.fillText(item.text, startX, startY); context.fillText(item.text, startX, startY);
context.closePath(); context.closePath();