consumer模块完成90%,前端完成supabase对接

This commit is contained in:
2026-02-04 17:21:15 +08:00
parent 8a535e3f38
commit 39aa1b6bec
1335 changed files with 191376 additions and 4 deletions

View File

@@ -0,0 +1,125 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: {
EChartsView
},
props: {
xLabels: { type: Array, default: () => [] },
data: { type: Array, default: () => [] },
height: { type: Number, default: 300 }
},
data() {
return {
heightPx: '300px',
chartOption: {} as any
}
},
watch: {
xLabels: { handler() { this.updateOption() }, deep: true },
data: { handler() { this.updateOption() }, deep: true },
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
setTimeout(() => {
this.updateOption()
}, 150)
},
methods: {
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
updateOption() {
if (!this.xLabels || !this.data || this.xLabels.length === 0 || this.data.length === 0) {
return
}
const x = (this.xLabels as Array<any>).map((s) => String(s))
const d = (this.data as Array<any>).map((v) => {
const n = Number(v)
return isFinite(n) ? n : 0
})
const option = {
grid: { left: 40, right: 20, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
boundaryGap: false,
data: x,
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.1)' } },
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10, interval: 'auto' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.05)' } },
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10 }
},
series: [{
data: d,
type: 'line',
smooth: true,
symbolSize: 0,
lineStyle: { width: 2, color: '#1890ff' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(24, 144, 255, 0.2)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.01)' }
]
}
}
}]
}
this.chartOption = this.toPlainObject(option)
}
}
}
</script>
<style scoped>
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
</style>

View File

@@ -0,0 +1,60 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type Item = { name: string; value: number }
export default {
components: {
EChartsView
},
props: {
items: { type: Array, default: () => [] },
height: { type: Number, default: 300 }
},
data() {
return {
heightPx: '300px',
chartOption: {} as any
}
},
watch: {
items: { handler() { this.updateOption() }, deep: true },
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
this.updateOption()
},
methods: {
updateOption() {
const x = (this.items as Array<Item>).map((it) => it.name)
const y = (this.items as Array<Item>).map((it) => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})
this.chartOption = {
grid: { left: 80, right: 24, top: 18, bottom: 18 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
yAxis: { type: 'category', data: x, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
series: [{ type: 'bar', data: y, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
}
}
</script>
<style>
.chart-wrap { width: 100%; }
.chart { width: 100%; height: 100%; }
</style>

View File

@@ -0,0 +1,246 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: {
EChartsView
},
props: {
xLabels: { type: Array, default: () => [] },
gmv: { type: Array, default: () => [] },
orders: { type: Array, default: () => [] },
height: { type: Number, default: 320 }
},
data() {
return {
heightPx: '320px',
chartOption: {} as any
}
},
watch: {
xLabels: {
handler() {
if (this.xLabels && this.xLabels.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
gmv: {
handler() {
if (this.gmv && this.gmv.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
orders: {
handler() {
if (this.orders && this.orders.length > 0) {
this.updateOption()
}
},
deep: true,
immediate: false
},
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
// 延迟初始化,确保 props 已传递
setTimeout(() => {
if (this.xLabels && this.xLabels.length > 0 && this.gmv && this.gmv.length > 0) {
this.updateOption()
}
}, 100)
},
methods: {
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
updateOption() {
// 检查数据是否有效
if (!this.xLabels || !this.gmv || !this.orders ||
this.xLabels.length === 0 || this.gmv.length === 0 || this.orders.length === 0) {
return
}
const x = (this.xLabels as Array<any>).map((s) => String(s))
const bar = (this.gmv as Array<any>).map((v) => {
const n = Number(v)
return isFinite(n) ? n : 0
})
const line = (this.orders as Array<any>).map((v) => {
const n = Number(v)
return isFinite(n) ? n : 0
})
// 构建图表配置并转换为纯 JS 对象
const option = {
grid: { left: 60, right: 60, top: 70, bottom: 40 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
let result = params[0].name + '<br/>'
for (let i = 0; i < params.length; i++) {
const p = params[i]
if (p.seriesName === '订单金额' || p.seriesName === 'GMV') {
const val = Number(p.value)
const formatted = val >= 10000 ? (val / 10000).toFixed(1) + '万' : val.toFixed(0)
result += `${p.marker} ${p.seriesName}: ¥${formatted}<br/>`
} else {
result += `${p.marker} ${p.seriesName}: ${p.value}<br/>`
}
}
return result
}
},
legend: {
top: 8,
left: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 12 },
data: ['订单金额', '订单数'],
bottom: 'auto'
},
xAxis: {
type: 'category',
data: x,
axisTick: { alignWithLabel: true },
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.12)' } },
axisLabel: {
color: 'rgba(0,0,0,0.55)',
rotate: x.length > 20 ? 45 : 0,
interval: x.length > 15 ? 1 : 0
}
},
yAxis: [
{
type: 'value',
name: '金额',
position: 'left',
axisLine: { show: false },
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
axisLabel: {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(0) + '万'
}
return String(Math.round(value))
}
}
},
{
type: 'value',
name: '数量',
position: 'right',
alignTicks: true,
axisLine: { show: false },
splitLine: { show: false },
axisLabel: {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => String(Math.round(value))
}
}
],
series: [
{
name: '订单金额',
type: 'bar',
yAxisIndex: 0,
data: bar,
barMaxWidth: 14,
barCategoryGap: '35%',
itemStyle: {
borderRadius: [2, 2, 0, 0],
color: '#3b82f6'
}
},
{
name: '订单数',
type: 'line',
yAxisIndex: 1,
data: line,
smooth: false,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#10b981'
},
itemStyle: {
color: '#10b981'
}
}
]
}
// 转换为纯 JS 对象确保 ECharts 能正确接收
this.chartOption = this.toPlainObject(option)
}
}
}
</script>
<style>
.chart-wrap {
width: 100%;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.chart {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<view class="date-range-picker">
<view class="picker-item">
<text class="label">开始日期</text>
<picker mode="date" :value="startDate" @change="onStartDateChange">
<view class="picker-value">{{ startDate || '请选择' }}</view>
</picker>
</view>
<view class="picker-item">
<text class="label">结束日期</text>
<picker mode="date" :value="endDate" @change="onEndDateChange">
<view class="picker-value">{{ endDate || '请选择' }}</view>
</picker>
</view>
<view class="actions">
<button class="btn apply" @click="applyRange">应用</button>
<button class="btn clear" @click="clearRange">清空</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, watch } from 'vue'
const props = defineProps({
initialStartDate: {
type: String,
default: ''
},
initialEndDate: {
type: String,
default: ''
}
})
const emit = defineEmits(['apply', 'clear'])
const startDate = ref(props.initialStartDate)
const endDate = ref(props.initialEndDate)
watch(() => props.initialStartDate, (val) => {
startDate.value = val
})
watch(() => props.initialEndDate, (val) => {
endDate.value = val
})
function onStartDateChange(e : any) {
startDate.value = e.detail.value
}
function onEndDateChange(e : any) {
endDate.value = e.detail.value
}
function applyRange() {
if (startDate.value && endDate.value) {
emit('apply', { start: startDate.value, end: endDate.value })
} else {
uni.showToast({ title: '请选择完整的日期范围', icon: 'none' })
}
}
function clearRange() {
startDate.value = ''
endDate.value = ''
emit('clear')
}
</script>
<style scoped>
.date-range-picker {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 8px;
}
.picker-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.label {
font-size: 14px;
color: #666;
}
.picker-value {
padding: 8px 12px;
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
color: #333;
}
.actions {
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.btn.apply {
background-color: #007bff;
color: #fff;
}
.btn.clear {
background-color: #6c757d;
color: #fff;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type Item = { name: string; value: number }
export default {
components: {
EChartsView
},
props: {
items: { type: Array, default: () => [] },
height: { type: Number, default: 300 }
},
data() {
return {
heightPx: '300px',
chartOption: {} as any
}
},
watch: {
items: { handler() { this.updateOption() }, deep: true },
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
this.updateOption()
},
methods: {
updateOption() {
const data = (this.items as Array<Item>).map((it) => ({
name: it.name,
value: (() => {
const n = Number(it.value)
return isFinite(n) ? n : 0
})()
}))
this.chartOption = {
tooltip: { trigger: 'item' },
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
series: [
{
type: 'pie',
radius: ['55%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}\n{d}%' },
labelLine: { length: 10, length2: 10 },
data
}
]
}
}
}
}
</script>
<style>
.chart-wrap { width: 100%; }
.chart { width: 100%; height: 100%; }
</style>

View File

@@ -0,0 +1,123 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: {
EChartsView
},
props: {
xLabels: { type: Array, default: () => [] },
series: { type: Array, default: () => [] },
height: { type: Number, default: 400 }
},
data() {
return {
heightPx: '400px',
chartOption: {} as any
}
},
watch: {
xLabels: { handler() { this.updateOption() }, deep: true },
series: { handler() { this.updateOption() }, deep: true },
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
setTimeout(() => {
this.updateOption()
}, 200)
},
methods: {
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
updateOption() {
if (!this.xLabels || this.xLabels.length === 0) return
const seriesData = (this.series as Array<any>).map(s => {
return {
name: s.name,
type: 'line',
smooth: true,
symbolSize: 6,
itemStyle: { color: s.color },
lineStyle: { width: 2 },
data: s.data
}
})
const option = {
grid: { left: 50, right: 30, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
legend: {
top: 0,
left: 'center',
itemWidth: 12,
itemHeight: 2,
textStyle: { fontSize: 12, color: '#333' }
},
xAxis: {
type: 'category',
boundaryGap: false,
data: this.xLabels,
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.1)' } },
axisLabel: {
color: 'rgba(0,0,0,0.45)',
fontSize: 10,
rotate: 45
}
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.05)' } },
axisLabel: { color: 'rgba(0,0,0,0.45)', fontSize: 10 }
},
series: seriesData
}
this.chartOption = this.toPlainObject(option)
}
}
}
</script>
<style scoped>
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
</style>

View File

@@ -0,0 +1,115 @@
<template>
<view class="chart-wrap" :style="{ height: heightPx }">
<EChartsView :option="chartOption" class="chart" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: {
EChartsView
},
props: {
items: { type: Array, default: () => [] },
height: { type: Number, default: 300 }
},
data() {
return {
heightPx: '300px',
chartOption: {} as any
}
},
watch: {
items: { handler() { this.updateOption() }, deep: true },
height: {
handler() {
this.heightPx = `${this.height}px`
}
}
},
mounted() {
this.heightPx = `${this.height}px`
setTimeout(() => {
this.updateOption()
}, 200)
},
methods: {
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
},
updateOption() {
if (!this.items || this.items.length === 0) return
const data = (this.items as Array<any>).map((it) => {
return {
name: String(it.name),
value: Number(it.value),
itemStyle: this.toPlainObject(it.itemStyle)
}
})
const option = {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
orient: 'vertical',
right: 10,
top: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 11, color: '#666' }
},
series: [{
type: 'pie',
radius: ['45%', '70%'],
center: ['35%', '50%'],
data: data,
avoidLabelOverlap: true,
label: {
show: false
},
labelLine: { show: false },
itemStyle: {
borderWidth: 2,
borderColor: '#fff'
}
}]
}
this.chartOption = this.toPlainObject(option)
}
}
}
</script>
<style scoped>
.chart-wrap { width: 100%; position: relative; overflow: hidden; }
.chart { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
</style>

View File

@@ -0,0 +1,379 @@
<template>
<view class="region-map">
<view class="map-head">
<view class="map-head-left">
<text class="map-title">销售地域分布</text>
<view class="map-switch">
<view
class="map-switch-btn"
:class="{ active: mapType === 'china' }"
@click="switchMapType('china')"
>
中国地图
</view>
<view
class="map-switch-btn"
:class="{ active: mapType === 'world' }"
@click="switchMapType('world')"
>
全国地图
</view>
</view>
</view>
</view>
<view v-if="loading || !chartOption || !chartOption.series || chartOption.series.length === 0" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="chartOption" :key="'map-' + mapType" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type RegionDataItem = { name: string; value: number }
export default {
components: {
EChartsView
},
props: {
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
topMerchants: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
},
data() {
return {
mapType: 'china', // 'china' | 'world'
regionData: [] as Array<RegionDataItem>,
chartOption: {} as any
}
},
watch: {
startDate: { handler() { this.loadData() }, deep: true },
endDate: { handler() { this.loadData() }, deep: true },
topMerchants: { handler() { this.loadData() }, deep: true }
},
mounted() {
this.loadData()
},
methods: {
switchMapType(type: string) {
this.mapType = type
this.buildChartOption()
},
async loadData() {
try {
// 暂时使用模拟数据,后续可以创建 RPC 函数获取真实省份数据
// 基于商家数据生成省份分布(模拟)
const mockProvinces: Array<RegionDataItem> = [
{ name: '广东', value: 0 },
{ name: '北京', value: 0 },
{ name: '上海', value: 0 },
{ name: '浙江', value: 0 },
{ name: '江苏', value: 0 },
{ name: '山东', value: 0 },
{ name: '河南', value: 0 },
{ name: '四川', value: 0 },
{ name: '湖北', value: 0 },
{ name: '湖南', value: 0 },
{ name: '福建', value: 0 },
{ name: '安徽', value: 0 },
{ name: '河北', value: 0 },
{ name: '陕西', value: 0 },
{ name: '江西', value: 0 },
{ name: '重庆', value: 0 },
{ name: '辽宁', value: 0 },
{ name: '云南', value: 0 },
{ name: '广西', value: 0 },
{ name: '山西', value: 0 },
{ name: '内蒙古', value: 0 },
{ name: '贵州', value: 0 },
{ name: '新疆', value: 0 },
{ name: '天津', value: 0 },
{ name: '吉林', value: 0 },
{ name: '黑龙江', value: 0 },
{ name: '海南', value: 0 },
{ name: '甘肃', value: 0 },
{ name: '宁夏', value: 0 },
{ name: '青海', value: 0 },
{ name: '西藏', value: 0 }
]
// 如果有商家数据,可以基于商家数量或 GMV 分配省份
const merchants = this.topMerchants as Array<any>
const totalSales = merchants.reduce((sum: number, m: any) => {
return sum + (Number(m.sales) || 0)
}, 0)
if (totalSales > 0 && merchants.length > 0) {
// 基于商家数量分配省份
const merchantCount = merchants.length
for (let i = 0; i < Math.min(merchantCount, mockProvinces.length); i++) {
const sales = Number(merchants[i].sales) || 0
mockProvinces[i].value = Math.round(sales * (Math.random() * 0.5 + 0.5))
}
} else {
// 使用随机数据
for (let i = 0; i < mockProvinces.length; i++) {
mockProvinces[i].value = Math.round(Math.random() * 100000)
}
}
this.regionData = mockProvinces
this.buildChartOption()
} catch (e) {
console.error('❌ AnalyticsRegionMap loadData failed', e)
this.regionData = []
this.buildChartOption()
}
},
buildChartOption() {
if (!this.regionData || this.regionData.length === 0) {
this.chartOption = {}
return
}
const maxValue = Math.max(...this.regionData.map((d) => d.value), 1)
if (this.mapType === 'china') {
// 中国地图配置(使用 geo 组件,兼容性更好)
this.chartOption = {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const name = params.name || '未知'
const value = params.value || 0
return `${name}<br/>销售额: ¥${this.formatMoney(value)}`
}
},
visualMap: {
min: 0,
max: maxValue,
left: 'left',
top: 'bottom',
text: ['高', '低'],
inRange: {
color: ['#e0f2fe', '#0ea5e9', '#0284c7', '#0369a1']
},
calculable: true,
textStyle: {
color: 'rgba(0,0,0,0.65)',
fontSize: 12
}
},
geo: {
map: 'china',
roam: false,
zoom: 1.5,
top: '40%',
itemStyle: {
areaColor: '#f0f0f0',
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
itemStyle: {
areaColor: '#0ea5e9'
},
label: {
show: true,
color: '#111'
}
}
},
series: [
{
name: '销售额',
type: 'map',
map: 'china',
geoIndex: 0,
data: this.regionData,
label: {
show: true,
fontSize: 11,
color: 'rgba(0,0,0,0.75)'
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
itemStyle: {
areaColor: '#0ea5e9',
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
color: '#fff',
fontSize: 12,
fontWeight: 'bold'
}
}
}
]
}
} else {
// 全国地图配置(简化版,使用柱状图展示 TOP 省份)
this.chartOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: { left: 60, right: 20, top: 36, bottom: 60 },
xAxis: {
type: 'category',
data: this.regionData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.name),
axisLabel: {
rotate: 45,
color: 'rgba(0,0,0,0.55)',
fontSize: 12
}
},
yAxis: {
type: 'value',
axisLabel: {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => {
if (value >= 10000) return (value / 10000).toFixed(1) + '万'
return String(Math.round(value))
}
},
splitLine: {
lineStyle: { color: 'rgba(0,0,0,0.06)' }
}
},
series: [
{
name: '销售额',
type: 'bar',
data: this.regionData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.value),
barWidth: 18,
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: '#3b82f6'
}
}
]
}
}
// 转换为纯 JS 对象
this.chartOption = this.toPlainObject(this.chartOption)
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
}
}
}
</script>
<style>
.region-map {
width: 100%;
}
.map-head {
margin-bottom: 8px;
}
.map-head-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
flex: 1;
}
.map-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.map-switch {
display: flex;
flex-direction: row;
gap: 4px;
background: #f3f4f6;
border-radius: 8px;
padding: 2px;
}
.map-switch-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: rgba(0,0,0,0.65);
background: transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.map-switch-btn.active {
background: #fff;
color: #111;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.chart-box {
width: 100%;
height: 360px;
}
.chart-loading {
width: 100%;
height: 360px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,435 @@
<!-- 数据分析侧边栏菜单组件 -->
<template>
<view>
<!-- 侧边栏菜单 -->
<view class="sidebar-menu" :class="{ active: showMenu, 'always-visible': isWideScreen }" @click.stop>
<view class="sidebar-content">
<view
v-for="item in menuItems"
:key="item.path"
>
<view
class="menu-item"
:class="{ active: isActive(item.path) }"
@click="onParentClick(item)"
>
<text class="menu-icon">{{ item.icon }}</text>
<text class="menu-text">{{ item.title }}</text>
<text v-if="item.children && item.children.length > 0" class="menu-caret">{{ isExpanded(item.path) ? '▾' : '▸' }}</text>
</view>
<view v-if="item.children && item.children.length > 0 && isExpanded(item.path)" class="submenu">
<view
v-for="child in item.children"
:key="child.path"
class="menu-item menu-item-child"
:class="{ active: isActive(child.path), disabled: isCustomReportChildDisabled(item, child) }"
@click="onChildClick(item, child)"
>
<text class="menu-icon">{{ child.icon }}</text>
<text class="menu-text">{{ child.title }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 遮罩层(仅窄屏时显示) -->
<view class="sidebar-overlay" v-if="showMenu && !isWideScreen" @click="closeMenu"></view>
</view>
</template>
<script lang="uts">
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
import { listCustomReports } from '@/services/analytics/customReportService.uts'
// 菜单项类型
type MenuItem = {
path: string
title: string
icon: string
children?: Array<MenuItem>
}
// 菜单配置
const MENU_ITEMS: Array<MenuItem> = [
{ path: '/pages/mall/analytics/index', title: '数据分析中心', icon: '📊' },
{ path: '/pages/mall/analytics/profile', title: '个人中心', icon: '👤' },
{ path: '/pages/mall/analytics/sales-report', title: '销售报表', icon: '💰' },
{ path: '/pages/mall/analytics/user-analysis', title: '用户分析', icon: '👥' },
{ path: '/pages/mall/analytics/product-insights', title: '商品洞察', icon: '📦' },
{ path: '/pages/mall/analytics/delivery-analysis', title: '配送效率分析', icon: '🚚' },
{ path: '/pages/mall/analytics/coupon-analysis', title: '优惠券效果分析', icon: '🎫' },
{ path: '/pages/mall/analytics/market-trends', title: '市场趋势', icon: '📈' },
{
path: '/pages/mall/analytics/custom-report',
title: '自定义报表',
icon: '📋',
children: [
{ path: '/pages/mall/analytics/report-detail', title: '报表详情', icon: '📄' },
{ path: '/pages/mall/analytics/data-detail', title: '数据分析详情', icon: '🔍' },
{ path: '/pages/mall/analytics/insight-detail', title: '数据洞察详情', icon: '💡' }
]
}
]
export default {
props: {
// 是否显示菜单
visible: {
type: Boolean,
default: false
},
// 当前页面路径
currentPath: {
type: String,
default: ''
}
},
emits: ['visible-change'],
data() {
return {
showMenu: false,
menuItems: MENU_ITEMS,
isWideScreen: false,
screenWidth: 0,
expanded: {} as any,
hasAnyCustomReport: false
}
},
watch: {
visible(newVal: boolean) {
// 宽屏时自动显示,窄屏时根据 visible 控制
if (this.isWideScreen) {
this.showMenu = true
} else {
this.showMenu = newVal
}
},
showMenu(newVal: boolean) {
// 同步到父组件(仅窄屏时)
if (!this.isWideScreen) {
this.$emit('visible-change', newVal)
}
},
currentPath: {
handler() {
this.syncExpandedWithRoute()
},
immediate: true
}
},
onLoad() {
this.checkScreenSize()
this.checkCustomReports()
},
onShow() {
// 每次显示时检查屏幕尺寸
this.checkScreenSize()
this.checkCustomReports()
},
methods: {
isActive(path: string): boolean {
const cur = this.currentPath || ''
if (cur === path) return true
if (cur.startsWith(path + '?')) return true
if (cur.startsWith(path + '/')) return true
return false
},
async checkCustomReports() {
try {
const uid = getUserIdOrNull()
if (uid == null || uid.length === 0) {
this.hasAnyCustomReport = false
return
}
const list = await listCustomReports(uid)
this.hasAnyCustomReport = Array.isArray(list) && list.length > 0
} catch (e) {
this.hasAnyCustomReport = false
}
},
checkScreenSize() {
// 获取屏幕宽度
const systemInfo = uni.getSystemInfoSync()
this.screenWidth = systemInfo.windowWidth || systemInfo.screenWidth
// 宽屏阈值960px与页面响应式断点一致
this.isWideScreen = this.screenWidth >= 960
// 宽屏时自动显示菜单
if (this.isWideScreen) {
this.showMenu = true
}
},
isExpanded(path: string): boolean {
const v: any = (this as any).expanded
return v != null && v[path] === true
},
toggleExpanded(path: string) {
const v: any = (this as any).expanded
if (v == null) return
v[path] = !(v[path] === true)
;(this as any).expanded = { ...v }
},
syncExpandedWithRoute() {
for (let i = 0; i < this.menuItems.length; i++) {
const item: any = this.menuItems[i]
if (item.children && item.children.length > 0) {
let shouldExpand = false
for (let j = 0; j < item.children.length; j++) {
const child = item.children[j]
if (this.isActive(child.path)) {
shouldExpand = true
break
}
}
if (this.isActive(item.path)) {
shouldExpand = true
}
const v: any = (this as any).expanded
v[item.path] = shouldExpand
}
}
const v: any = (this as any).expanded
;(this as any).expanded = { ...v }
},
isCustomReportChildDisabled(parent: any, child: any): boolean {
if (parent == null || child == null) return false
if (parent.path !== '/pages/mall/analytics/custom-report') return false
return this.hasAnyCustomReport !== true
},
onChildClick(parent: any, child: any) {
if (this.isCustomReportChildDisabled(parent, child)) {
uni.showToast({ title: '请先创建自定义报表', icon: 'none', duration: 2000 })
// 引导去自定义报表页
this.navigateToPage('/pages/mall/analytics/custom-report')
return
}
this.navigateToPage(child.path)
},
onParentClick(item: any) {
if (item.children && item.children.length > 0) {
// 有子菜单:
// - 如果当前就在该父级页面:切换展开/收起
// - 否则:先跳转到父级页面(自定义报表列表),并确保展开
if (this.isActive(item.path)) {
this.toggleExpanded(item.path)
} else {
const v: any = (this as any).expanded
if (v != null) {
v[item.path] = true
;(this as any).expanded = { ...v }
}
this.navigateToPage(item.path)
}
return
}
this.navigateToPage(item.path)
},
closeMenu() {
// 宽屏时不允许关闭
if (this.isWideScreen) {
return
}
this.showMenu = false
this.$emit('visible-change', false)
},
navigateToPage(path: string) {
if (this.currentPath === path) {
// 窄屏时关闭菜单
if (!this.isWideScreen) {
this.closeMenu()
}
return
}
uni.redirectTo({
url: path,
fail: () => {
// navigateTo 失败时通常是页面栈满最多10层这里降级为 redirectTo
uni.navigateTo({
url: path,
fail: () => {
uni.showToast({ title: '页面跳转失败', icon: 'none' })
}
})
}
})
// 窄屏时关闭菜单
if (!this.isWideScreen) {
this.closeMenu()
}
}
}
}
</script>
<style>
/* 侧边栏菜单 */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 998;
animation: fadeIn 0.3s ease;
}
.sidebar-menu {
position: fixed;
top: 0;
left: 0;
width: 280px;
height: 100vh;
background: #fff;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
z-index: 999;
transform: translateX(-100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
/* 窄屏:抽屉效果 */
.sidebar-menu.active {
transform: translateX(0);
}
/* 宽屏:固定显示在左侧 */
.sidebar-menu.always-visible {
position: relative;
transform: translateX(0);
box-shadow: none;
border-right: 1px solid rgba(0, 0, 0, 0.06);
z-index: 1;
}
.sidebar-header {
display: flex;
flex-direction: row !important;
justify-content: flex-end;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.sidebar-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.sidebar-close:hover {
background: rgba(0, 0, 0, 0.05);
}
.sidebar-close .icon {
font-size: 20px;
color: #666;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.menu-item {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.submenu {
padding-left: 28px;
}
.menu-item-child {
padding: 10px 16px;
}
.menu-caret {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
flex: 0 0 auto;
}
.menu-item:hover {
background: rgba(0, 0, 0, 0.03);
}
.menu-item.active {
background: rgba(59, 130, 246, 0.1);
border-left: 3px solid #3b82f6;
}
.menu-item.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-item.disabled:hover {
background: transparent;
}
.menu-item.active .menu-text {
color: #3b82f6;
font-weight: 600;
}
.menu-icon {
font-size: 20px;
width: 24px;
text-align: center;
}
.menu-text {
font-size: 15px;
color: #333;
flex: 1;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 响应式:宽屏时固定显示 */
@media screen and (min-width: 960px) {
.sidebar-menu {
position: relative;
transform: translateX(0);
box-shadow: none;
border-right: 1px solid rgba(0, 0, 0, 0.06);
z-index: 1;
}
.sidebar-overlay {
display: none;
}
}
</style>

View File

@@ -0,0 +1,332 @@
<!-- 数据分析顶部导航栏组件 -->
<template>
<view class="analytics-topbar">
<view class="topbar-left">
<!-- 仅窄屏且侧边栏未打开时显示菜单按钮 -->
<view class="menu-icon" v-if="showMenuIcon" @click="handleMenu">
<text class="icon">☰</text>
</view>
<view class="title-group">
<text class="title">{{ title }}</text>
<text class="subtitle">最后更新:{{ lastUpdateTime }}</text>
</view>
</view>
<view class="topbar-right">
<!-- 宽屏时显示的按钮 -->
<view class="icon-btn-icon btn-visible" @click="handleRefresh">
<text class="icon">🔄</text>
</view>
<view class="icon-btn-icon btn-visible" @click="handleSearch">
<text class="icon">🔍</text>
</view>
<view class="icon-btn-icon notification btn-hidden" @click="handleNotification">
<text class="icon">🔔</text>
<view class="badge"></view>
</view>
<view class="icon-btn-icon btn-hidden" @click="handleFullscreen">
<text class="icon">⛶</text>
</view>
<view class="icon-btn-icon btn-hidden" @click="handleMobile">
<text class="icon">📱</text>
</view>
<view class="dropdown btn-visible" @click="handleDropdown">
<text class="dropdown-text">crmeb demo</text>
<text class="dropdown-arrow">▼</text>
</view>
<view class="icon-btn-icon btn-hidden" @click="handleSettings">
<text class="icon">⚙️</text>
</view>
<!-- 更多按钮(窄屏时显示) -->
<view class="more-btn" :class="{ active: showMoreMenu }" @click.stop="toggleMoreMenu">
<text class="icon">⋯</text>
</view>
</view>
<!-- 更多菜单下拉 -->
<view class="more-menu" v-if="showMoreMenu" @click.stop>
<view class="more-menu-item" @click="handleNotification">
<text class="icon">🔔</text>
<text>通知</text>
</view>
<view class="more-menu-item" @click="handleFullscreen">
<text class="icon">⛶</text>
<text>全屏</text>
</view>
<view class="more-menu-item" @click="handleMobile">
<text class="icon">📱</text>
<text>移动端</text>
</view>
<view class="more-menu-item" @click="handleSettings">
<text class="icon">⚙️</text>
<text>设置</text>
</view>
</view>
</view>
</template>
<script lang="uts">
export default {
props: {
title: {
type: String,
default: '数据分析中心'
},
lastUpdateTime: {
type: String,
default: ''
},
// 由页面传入:当前侧边栏是否处于“打开/显示”状态(窄屏下用于隐藏菜单按钮)
sidebarVisible: {
type: Boolean,
default: false
}
},
data() {
return {
showMoreMenu: false,
isWideScreen: false
}
},
computed: {
showMenuIcon(): boolean {
// 宽屏不显示;窄屏仅在侧边栏未打开时显示
return !this.isWideScreen && !this.sidebarVisible
}
},
onLoad() {
this.checkScreenSize()
},
onShow() {
this.checkScreenSize()
},
methods: {
checkScreenSize() {
const systemInfo = uni.getSystemInfoSync()
const w = systemInfo.windowWidth || systemInfo.screenWidth
// 与侧边栏一致960px 以上视为宽屏
this.isWideScreen = w >= 960
},
handleMenu() {
this.$emit('menu-click')
},
handleRefresh() {
this.$emit('refresh')
},
handleSearch() {
this.$emit('search')
},
handleNotification() {
this.showMoreMenu = false
this.$emit('notification')
},
handleFullscreen() {
this.showMoreMenu = false
this.$emit('fullscreen')
},
handleMobile() {
this.showMoreMenu = false
this.$emit('mobile')
},
handleDropdown() {
this.$emit('dropdown')
},
handleSettings() {
this.showMoreMenu = false
this.$emit('settings')
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
}
}
}
</script>
<style>
.analytics-topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100%;
height: 64px;
background: #ffffff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
padding: 0 16px;
z-index: 1000;
box-sizing: border-box;
}
.topbar-left {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f3f4f6;
margin-right: 12px;
flex-shrink: 0;
}
.menu-icon .icon {
font-size: 18px;
color: #333;
}
.title-group {
display: flex;
flex-direction: row;
align-items: baseline;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 8px;
}
.topbar-right {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.icon-btn-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f3f4f6;
position: relative;
margin-left: 8px;
}
.icon-btn-icon .icon {
font-size: 18px;
color: #333;
}
.icon-btn-icon.notification .badge {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
border: 2px solid #ffffff;
}
.dropdown {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
background: #f3f4f6;
margin-left: 8px;
}
.dropdown-text {
font-size: 14px;
color: #333;
font-weight: 500;
}
.dropdown-arrow {
font-size: 10px;
color: #666;
}
.more-btn {
width: 40px;
height: 40px;
display: none;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f3f4f6;
position: relative;
margin-left: 8px;
}
.more-btn .icon {
font-size: 20px;
color: #333;
}
.more-menu {
position: absolute;
top: 100%;
right: 16px;
margin-top: 8px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 8px 0;
min-width: 160px;
z-index: 1001;
}
.more-menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
}
.more-menu-item .icon {
font-size: 18px;
}
.more-menu-item text {
font-size: 14px;
color: #333;
}
/* 响应式 */
@media screen and (max-width: 960px) {
.btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
.title,
.subtitle {
max-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<view class="gender-card">
<view class="card-header">
<text class="title">用户性别比例</text>
</view>
<view class="card-content">
<view class="chart-container">
<EChartsView :option="chartOption" class="donut-chart" />
<view class="center-text">
<text class="total-label">总用户数</text>
<text class="total-value">{{ totalUsers }}</text>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: {
EChartsView
},
data() {
return {
totalUsers: 525,
chartOption: {} as any
}
},
mounted() {
setTimeout(() => {
this.initChart()
}, 200)
},
methods: {
initChart() {
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
top: '0%',
left: 'center',
icon: 'rect',
itemWidth: 15,
itemHeight: 15,
textStyle: {
fontSize: 12,
color: '#666'
}
},
series: [
{
name: '性别比例',
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '60%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: false
}
},
labelLine: {
show: false
},
data: [
{ value: 450, name: '未知', itemStyle: { color: '#999999' } },
{ value: 50, name: '男', itemStyle: { color: '#3b82f6' } },
{ value: 25, name: '女', itemStyle: { color: '#f97316' } }
]
}
]
}
this.chartOption = this.toPlainObject(option)
},
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
}
}
}
</script>
<style scoped lang="scss">
.gender-card {
background: #fff;
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
height: 521px;
}
.card-header {
margin-bottom: 20px;
}
.title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.card-content {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.chart-container {
position: relative;
width: 100%;
height: 100%;
}
.donut-chart {
width: 100%;
height: 100%;
}
.center-text {
position: absolute;
top: 60%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.total-label {
font-size: 14px;
color: #999;
margin-bottom: 4px;
}
.total-value {
font-size: 28px;
font-weight: bold;
color: #333;
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<view class="user-map-card">
<view class="card-header">
<text class="title">用户地域分布</text>
</view>
<view class="card-content">
<!-- 左侧地图 -->
<view class="map-section">
<EChartsView :option="mapOption" class="map-chart" />
</view>
<!-- 右侧表格 -->
<view class="table-section">
<view class="table-header">
<text class="th province">TOP省份</text>
<text class="th count">累积用户数</text>
<text class="th count">新增用户数</text>
<text class="th count">访客数</text>
<text class="th amount">支付金额</text>
</view>
<scroll-view class="table-body" scroll-y="true">
<view v-for="(item, index) in tableData" :key="index" class="table-row">
<text class="td province">{{ item.name }}</text>
<text class="td count">{{ item.total }}</text>
<text class="td count">{{ item.newUsers }}</text>
<text class="td count">{{ item.visitors }}</text>
<text class="td amount">{{ item.amount }}</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
type RegionData = {
name: string
total: number
newUsers: number
visitors: number
amount: number
}
export default {
components: {
EChartsView
},
data() {
return {
mapOption: {} as any,
tableData: [
{ name: '未知', total: 55398, newUsers: 463, visitors: 1112, amount: 287460.5 },
{ name: '广东', total: 2697, newUsers: 0, visitors: 9, amount: 0 },
{ name: '北京', total: 1058, newUsers: 0, visitors: 4, amount: 0 },
{ name: '河南', total: 823, newUsers: 0, visitors: 3, amount: 0 },
{ name: '福建', total: 799, newUsers: 0, visitors: 4, amount: 0 },
{ name: '山东', total: 792, newUsers: 0, visitors: 0, amount: 0 },
{ name: '浙江', total: 780, newUsers: 0, visitors: 1, amount: 0 },
{ name: '江苏', total: 779, newUsers: 0, visitors: 4, amount: 0 },
{ name: '四川', total: 768, newUsers: 0, visitors: 3, amount: 0 },
{ name: '湖南', total: 520, newUsers: 0, visitors: 2, amount: 0 },
{ name: '湖北', total: 480, newUsers: 0, visitors: 1, amount: 0 }
] as Array<RegionData>
}
},
mounted() {
setTimeout(() => {
this.initMap()
}, 200)
},
methods: {
initMap() {
// 提取地图数据
const data = this.tableData.filter(it => it.name !== '未知').map(it => ({
name: it.name,
value: it.total
}))
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}<br/>累积用户: {c}'
},
visualMap: {
min: 0,
max: 3000,
left: 'left',
top: 'bottom',
text: ['高', '低'],
inRange: {
color: ['#fff7ed', '#fbbf24', '#f59e0b', '#d97706']
},
calculable: true
},
geo: {
map: 'china',
roam: false,
zoom: 1.2,
emphasis: {
itemStyle: {
areaColor: '#f97316'
},
label: {
show: true
}
},
itemStyle: {
areaColor: '#f8fafc',
borderColor: '#e2e8f0'
}
},
series: [
{
name: '用户数',
type: 'map',
geoIndex: 0,
data: data
}
]
}
this.mapOption = this.toPlainObject(option)
},
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
}
}
return plain
}
}
}
</script>
<style scoped lang="scss">
.user-map-card {
background: #fff;
border-radius: 4px;
padding: 20px;
margin-bottom: 16px;
}
.card-header {
margin-bottom: 20px;
}
.title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.card-content {
display: flex;
flex-direction: row;
height: 450px;
}
.map-section {
flex: 4;
height: 100%;
}
.map-chart {
width: 100%;
height: 100%;
}
.table-section {
flex: 6;
display: flex;
flex-direction: column;
padding-left: 20px;
}
.table-header {
display: flex;
flex-direction: row;
background: #f8faff;
padding: 12px 0;
border-bottom: 1px solid #eef2f7;
}
.table-body {
flex: 1;
height: 0;
}
.table-row {
display: flex;
flex-direction: row;
padding: 12px 0;
border-bottom: 1px solid #f0f2f5;
&:hover {
background: #f9fbff;
}
}
.th, .td {
font-size: 13px;
color: #666;
text-align: center;
}
.province { flex: 1.5; text-align: left; padding-left: 15px; }
.count { flex: 1.5; }
.amount { flex: 2; }
.th {
color: #333;
font-weight: 500;
}
.td {
color: #666;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="card">
<view class="hd">
<view class="left">
<text class="t">{{ title }}</text>
<text class="d" v-if="desc">{{ desc }}</text>
</view>
<slot name="extra"></slot>
</view>
<view class="bd">
<slot></slot>
</view>
</view>
</template>
<script lang="uts">
export default {
props: {
title: { type: String, default: '' },
desc: { type: String, default: '' }
}
}
</script>
<style>
.card {
border: 1rpx solid rgba(17, 17, 17, 0.08);
border-radius: 16rpx;
padding: 14rpx;
background: #fff;
flex: 1;
}
.hd {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10rpx;
}
.t {
font-size: 24rpx;
font-weight: 800;
color: #111;
}
.d {
font-size: 20rpx;
color: rgba(17, 17, 17, 0.55);
margin-top: 4rpx;
display: block;
}
.bd {
padding-top: 4rpx;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<view class="kpi" :class="tone">
<text class="t">{{ title }}</text>
<text class="v">{{ value }}</text>
<view class="row" v-if="!deltaHidden">
<text class="delta" :class="delta >= 0 ? 'pos' : 'neg'">
{{ delta >= 0 ? '+' : '' }}{{ delta.toFixed(1) }}%
</text>
<text class="s">{{ subtitle }}</text>
</view>
<text class="s" v-else>{{ subtitle }}</text>
</view>
</template>
<script lang="uts">
export default {
props: {
title: { type: String, default: '' },
value: { type: String, default: '' },
subtitle: { type: String, default: '' },
delta: { type: Number, default: 0 },
tone: { type: String, default: 'danger' },
deltaHidden: { type: Boolean, default: false }
}
}
</script>
<style>
.kpi {
width: calc(50% - 7rpx);
padding: 16rpx;
border-radius: 16rpx;
color: #fff;
}
.t {
font-size: 22rpx;
opacity: 0.9;
}
.v {
font-size: 36rpx;
font-weight: 800;
margin-top: 10rpx;
display: block;
}
.row {
display: flex;
align-items: center;
gap: 10rpx;
margin-top: 10rpx;
}
.s {
font-size: 20rpx;
opacity: 0.85;
}
.delta {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
font-weight: 700;
}
.delta.pos {
}
.delta.neg {
}
/* tones */
.danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ff4d4f 100%);
}
.teal {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
}
.green {
background: linear-gradient(135deg, #a8e6cf 0%, #7fcdbb 100%);
}
.amber {
background: linear-gradient(135deg, #ffd93d 0%, #ffa07a 100%);
}
@media screen and (max-width: 768px) {
.kpi {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<view class="tabs">
<view
v-for="it in items"
:key="it.value"
class="tab"
:class="value === it.value ? 'on' : ''"
@click="pick(it.value)"
>
<text>{{ it.label }}</text>
</view>
</view>
</template>
<script lang="uts">
export default {
props: {
value: { type: String, default: '30d' },
items: { type: Array, default: () => [] }
},
methods: {
pick(v: string) {
this.$emit('change', v)
}
}
}
</script>
<style>
.tabs {
display: flex;
background: rgba(17, 17, 17, 0.04);
border-radius: 999rpx;
padding: 6rpx;
}
.tab {
padding: 10rpx 16rpx;
border-radius: 999rpx;
color: rgba(17, 17, 17, 0.65);
font-size: 22rpx;
}
.tab.on {
color: #fff;
font-weight: 700;
background: linear-gradient(135deg, #ff4d4f 0%, #ff7a45 100%);
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="wrap" :style="{ height: height + 'px' }">
<EChartsView :option="option" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: { EChartsView },
props: {
option: { type: Object, default: () => ({}) },
height: { type: Number, default: 280 }
}
}
</script>
<style>
.wrap {
width: 100%;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="wrap" :style="{ height: height + 'px' }">
<EChartsView :option="option" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: { EChartsView },
props: {
option: { type: Object, default: () => ({}) },
height: { type: Number, default: 320 }
}
}
</script>
<style>
.wrap {
width: 100%;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<view class="wrap" :style="{ height: height + 'px' }">
<EChartsView :option="option" />
</view>
</template>
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
export default {
components: { EChartsView },
props: {
option: { type: Object, default: () => ({}) },
height: { type: Number, default: 280 }
}
}
</script>
<style>
.wrap {
width: 100%;
}
</style>

View File

@@ -0,0 +1,533 @@
<template>
<view class="region-selector">
<view class="region-filter">
<view class="level-tabs">
<view
v-for="(level, index) in availableLevels"
:key="level.value"
:class="['level-tab', { active: currentLevelIndex === index }]"
@click="selectLevel(level.value, index)"
>
<text>{{ level.label }}</text>
</view>
</view>
<view class="selected-path" v-if="showPath && selectedPath.length > 0">
<view
v-for="(region, index) in selectedPath"
:key="region.id"
class="path-item"
>
<text
class="path-text"
@click="navigateToPathItem(index)"
>{{ region.name }}</text>
<text class="path-separator" v-if="index < selectedPath.length - 1"> / </text>
</view>
</view>
</view>
<view class="region-list" v-if="regions.length > 0">
<view class="region-items">
<view
v-for="region in regions"
:key="region.id"
class="region-item"
@click="selectRegion(region)"
>
<view class="region-info">
<text class="region-name">{{ region.name }}</text>
<view class="region-meta" v-if="showStats">
<text class="region-count">{{ region.children_count || 0 }} 个下级区域</text>
<text class="region-count">{{ region.school_count || 0 }} 所学校</text>
</view>
</view>
<text class="region-arrow">></text>
</view>
</view>
</view>
<view class="empty-state" v-else-if="!loading">
<text>当前没有{{ currentLevelLabel }}级区域数据</text>
<button
v-if="canCreate"
class="add-btn"
@click="$emit('create', { parentId: currentParentId, level: currentLevel })"
>
添加{{ currentLevelLabel }}
</button>
</view>
<view class="loading" v-if="loading">
<text>加载中...</text>
</view>
</view>
</template>
<script lang="uts">
import { ref, computed, onMounted, watch } from 'vue'
import { SupaDB } from './supadb.uvue'
// 定义区域数据接口
interface Region {
id: string
name: string
level: number
parent_id?: string
children_count?: number
school_count?: number
}
// 定义级别选项接口
interface LevelOption {
value: number
label: string
}
export default {
name: 'RegionSelector',
props: {
// 初始选中的区域ID
initialRegionId: {
type: String,
default: ''
},
// 是否显示路径导航
showPath: {
type: Boolean,
default: true
},
// 是否显示统计数据
showStats: {
type: Boolean,
default: true
},
// 是否可以创建新区域
canCreate: {
type: Boolean,
default: false
},
// 可用的区域级别,如果为空则使用所有级别
allowedLevels: {
type: Array,
default: () => []
}
},
setup(props: any, { emit }: any) {
const db = new SupaDB()
// 状态
const loading = ref(false)
const regions = ref<Region[]>([])
const selectedPath = ref<Region[]>([])
const currentLevel = ref(1) // 默认省级
const currentLevelIndex = ref(0)
const currentParentId = ref('')
// 级别常量
const REGION_LEVELS: LevelOption[] = [
{ value: 1, label: '省/直辖市' },
{ value: 2, label: '市/区' },
{ value: 3, label: '县/区' },
{ value: 4, label: '乡镇/街道' }
]
// 计算属性:可用的级别
const availableLevels = computed(() => {
if (props.allowedLevels && props.allowedLevels.length > 0) {
// Replace filter with for loop for UTS compatibility
let filteredLevels = []
for (let i = 0; i < REGION_LEVELS.length; i++) {
const level = REGION_LEVELS[i]
if (props.allowedLevels.includes(level.value)) {
filteredLevels.push(level)
}
}
return filteredLevels
}
return REGION_LEVELS
})
// 当前级别标签
const currentLevelLabel = computed(() => {
const level = REGION_LEVELS.find(l => l.value === currentLevel.value)
return level ? level.label : ''
})
// 初始化
onMounted(async () => {
// 如果有初始区域ID则加载该区域及其路径
if (props.initialRegionId) {
await loadRegionAndPath(props.initialRegionId)
} else {
// 否则加载顶级区域
await loadRegions()
}
})
// 监听初始区域ID变化
watch(() => props.initialRegionId, async (newVal) => {
if (newVal) {
await loadRegionAndPath(newVal)
}
})
// 加载区域数据
const loadRegions = async (parentId?: string) => {
loading.value = true
try {
let query = db.from('ak_regions')
.select('*, children:ak_regions!parent_id(count), schools:ak_schools(count)')
.eq('level', currentLevel.value)
.order('name')
if (parentId) {
query = query.eq('parent_id', parentId)
} else if (currentLevel.value !== 1) {
// 非顶级区域但无父ID显示空数据
regions.value = []
loading.value = false
return
}
const { data, error } = await query
if (error) {
console.error('加载区域数据失败:', error)
regions.value = []
return
}
if (data) {
// Replace map with for loop for UTS compatibility
let mappedRegions : Region[] = []
for (let i = 0; i < data.length; i++) {
const item = data[i]
mappedRegions.push({
...item,
children_count: (item.children && item.children.length) ? item.children[0].count : 0,
school_count: (item.schools && item.schools.length) ? item.schools[0].count : 0
} as Region)
}
regions.value = mappedRegions
} else {
regions.value = []
}
} catch (e) {
console.error('加载区域数据异常:', e)
regions.value = []
} finally {
loading.value = false
}
}
// 加载区域及其路径
const loadRegionAndPath = async (regionId: string) => {
loading.value = true
try {
// 获取区域详情
const { data, error } = await db.from('ak_regions')
.select('*')
.eq('id', regionId)
.single()
if (error) {
console.error('获取区域详情失败:', error)
return
}
if (!data) return
const region = data as Region
// 设置当前级别
currentLevel.value = region.level
const levelIndex = availableLevels.value.findIndex(l => l.value === region.level)
if (levelIndex >= 0) {
currentLevelIndex.value = levelIndex
}
// 获取路径
await loadRegionPath(region)
// 加载同级区域
if (region.parent_id) {
currentParentId.value = region.parent_id
await loadRegions(region.parent_id)
} else {
currentParentId.value = ''
await loadRegions()
}
// 触发选择事件
emit('select', region)
} catch (e) {
console.error('加载区域及路径异常:', e)
} finally {
loading.value = false
}
}
// 加载区域路径
const loadRegionPath = async (region: Region) => {
try {
// 先将当前区域添加到路径
selectedPath.value = [region]
let currentParent = region.parent_id
// 循环获取所有父区域
while (currentParent) {
const { data, error } = await db.from('ak_regions')
.select('*')
.eq('id', currentParent)
.single()
if (error || !data) break
const parentRegion = data as Region
// 将父区域添加到路径前面
selectedPath.value.unshift(parentRegion)
// 继续向上级查找
currentParent = parentRegion.parent_id
}
} catch (e) {
console.error('加载区域路径异常:', e)
}
}
// 选择区域级别
const selectLevel = async (level: number, index: number) => {
currentLevel.value = level
currentLevelIndex.value = index
// 根据当前路径确定父级ID
if (selectedPath.value.length > 0) {
// 找到合适的父级
const parent = selectedPath.value.find(r => r.level === level - 1)
if (parent) {
// 找到合适的父级,更新路径
const pathIndex = selectedPath.value.indexOf(parent)
selectedPath.value = selectedPath.value.slice(0, pathIndex + 1)
currentParentId.value = parent.id
} else {
// 未找到合适的父级,重置路径
selectedPath.value = []
currentParentId.value = ''
}
} else {
currentParentId.value = ''
}
await loadRegions(currentParentId.value || undefined)
}
// 选择区域
const selectRegion = async (region: Region) => {
// 如果已在路径中,则不重复添加
if (selectedPath.value.some(r => r.id === region.id)) {
return
}
// 更新路径
if (region.level > 1 && selectedPath.value.length === 0) {
// 如果是选择非顶级区域且路径为空,需要加载完整路径
await loadRegionAndPath(region.id)
} else {
// 否则直接添加到路径末尾
selectedPath.value.push(region)
// 如果有下级区域,自动切换到下级
if (region.children_count && region.children_count > 0) {
const nextLevel = region.level + 1
const nextLevelOption = availableLevels.value.find(l => l.value === nextLevel)
if (nextLevelOption) {
const nextLevelIndex = availableLevels.value.indexOf(nextLevelOption)
currentLevel.value = nextLevel
currentLevelIndex.value = nextLevelIndex
currentParentId.value = region.id
await loadRegions(region.id)
}
}
}
// 触发选择事件
emit('select', region)
}
// 导航到路径项
const navigateToPathItem = async (index: number) => {
if (index >= selectedPath.value.length) return
const pathItem = selectedPath.value[index]
// 更新路径
selectedPath.value = selectedPath.value.slice(0, index + 1)
// 更新级别
currentLevel.value = pathItem.level
const levelIndex = availableLevels.value.findIndex(l => l.value === pathItem.level)
if (levelIndex >= 0) {
currentLevelIndex.value = levelIndex
}
// 更新父ID
if (index === 0) {
currentParentId.value = ''
} else {
currentParentId.value = selectedPath.value[index - 1].id
}
// 加载区域
await loadRegions(currentParentId.value || undefined)
// 触发选择事件
emit('select', pathItem)
}
return {
loading,
regions,
selectedPath,
currentLevel,
currentLevelIndex,
currentParentId,
availableLevels,
currentLevelLabel,
// 方法
selectLevel,
selectRegion,
navigateToPathItem
}
}
}
</script>
<style>
.region-selector {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.region-filter {
padding: 15px;
border-bottom: 1px solid #eee;
}
.level-tabs {
display: flex;
margin-bottom: 10px;
}
.level-tab {
padding: 6px 12px;
margin-right: 8px;
border-radius: 4px;
background-color: #f5f5f5;
cursor: pointer;
}
.level-tab.active {
background-color: #1890ff;
color: #fff;
}
.selected-path {
display: flex;
flex-wrap: wrap;
padding: 8px 0;
}
.path-item {
display: flex;
align-items: center;
margin-right: 5px;
}
.path-text {
color: #1890ff;
font-size: 14px;
cursor: pointer;
}
.path-separator {
color: #bbb;
margin: 0 5px;
}
.region-list {
max-height: 400px;
overflow-y: auto;
}
.region-items {
padding: 0 15px;
}
.region-item {
padding: 12px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.region-item:last-child {
border-bottom: none;
}
.region-info {
flex: 1;
}
.region-name {
font-size: 16px;
color: #333;
margin-bottom: 5px;
}
.region-meta {
display: flex;
}
.region-count {
font-size: 12px;
color: #999;
margin-right: 15px;
}
.region-arrow {
color: #bbb;
font-size: 16px;
}
.empty-state {
padding: 30px 15px;
text-align: center;
color: #999;
}
.add-btn {
margin-top: 15px;
background-color: #1890ff;
color: #fff;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.loading {
padding: 20px;
text-align: center;
color: #999;
}
</style>

View File

@@ -0,0 +1,41 @@
**Supabase 会话恢复说明supa session recovery**
- **目的**: 说明为什么刷新后 `session` / `user` 变为 `null`,以及我在项目中做了哪些修改来改善会话恢复。
- **文件变更**:
- **`components/supadb/aksupainstance.uts`**: 统一 Supabase 实例导出,新增 `supaReady` 初始化流程。在初始化时尝试从持久化 token`AkReq` 存储)读取 `access_token` / `refresh_token`,将 refresh token 注入到 `supa.session` 并调用 `supa.refreshSession()` 以恢复会话;保留 `ensureSupabaseReady()` 作为兼容接口。
- **`utils/store.uts`**: 将 `import` 改为 `import supa, { supaReady }`,并在获取 session 前 `await supaReady`,确保恢复逻辑已经运行完毕。
- **`utils/sapi.uts`**、**`pages/sense/senseDataService.uts`** 及若干页面文件: 替换对旧 `ensureSupabaseReady` 的调用,改为 `await supaReady`,并使用默认导出的 `supa` 实例。
- **问题根本原因**:
1. 系统原先将会话信息保持在 `AkSupa.session` / `AkSupa.user`(内存)中,页面刷新或应用重启会清空内存,导致 `supa.getSession()` 返回 `null`
2. 虽然 `AkReq.setToken``access_token` / `refresh_token` 持久化到 `uni` storage`AkSupa` 在启动时没有读取这些持久化 token 并执行恢复/刷新流程(或未将 refresh token 注入到 `AkSupa`),因此无法重建会话。
3. 若 refresh token 过期或没有被正确持久化,也会导致恢复失败。
- **我做的修改说明(要点)**:
-`aksupainstance.uts` 添加 `supaReady`:这是一个 Promise模块初始化时会尝试读取 `AkReq.getRefreshToken()` / `AkReq.getToken()`;若找到 refresh token则把其临时赋到 `supa.session`,再调用 `supa.refreshSession()` 以更新内存中的 session 和 user。
- 在所有依赖会话的模块中,在执行数据库请求前 `await supaReady`,确保恢复尝试已完成,避免 race condition页面刷新后立即调用 supa API 导致 401/空 session
- 保留向后兼容接口 `ensureSupabaseReady()`(内部直接返回 `supaReady`)。
- **如何验证(开发环境)**:
1. 登录并确认本地存储中有 token通过控制台或在 uni 环境运行):
- `uni.getStorageSync('akreq_access_token')`
- `uni.getStorageSync('akreq_refresh_token')`
2. 刷新页面或重启应用,打开控制台查找初始化日志或错误(`Supabase instance init failed` 等)。
3. 在页面中(例如 `pages/mall/delivery/index.uvue`查看已有的调试输出search `supa session=` 的 console 日志,应显示非 null session若 refresh 成功)。
4. 若恢复失败,请检查后端 refresh 接口是否返回 200以及 refresh token 是否已过期。
- **后续建议(可选)**:
-`AkSupa.signIn` / `refreshSession` 成功时,将完整 session或至少 `refresh_token`)写入 `uni.setStorageSync`(持久化),并在 `signOut` 时清除,这样可以进一步减少恢复失败的情况。
-`supa.session` 的 JSON 快照也写入 storage作为额外冗余并在 init 时尝试直接恢复(注意安全和敏感信息保护)。
- 增加更详细的日志(成功/失败原因),并在 UI 层对“登录过期”做更友好的提示或自动跳转到登录页。
- **注意事项**:
- 切勿在代码中硬编码匿名 key 或生产密钥;请在 `ak/config.uts` 中正确配置 `SUPA_URL``SUPA_KEY`
- refresh token 本身也是敏感凭证,应妥善保管;如需长期保持登录,建议使用 refresh 流程并合理设置过期与刷新策略。
如果你需要,我可以继续:
-`AkSupa` 中实现“登录后持久化 session JSON”的补丁并在 `signOut` 时清理;
- 或添加更详细的调试输出帮助定位某次具体恢复失败的 HTTP 请求和响应。
文件路径components/supadb/SESSION_RECOVERY.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
//import AkSupa from './aksupa.uts'
//import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts'
//const supa = new AkSupa(SUPA_URL, SUPA_KEY)
//const supaReady: Promise<boolean> = (async () => {
/// try {
// await supa.signIn('akoo@163.com', 'Hf2152111')
// await supa.signIn('am@163.com', 'kookoo')
// return true
// } catch (err) {
// console.error('Supabase auto sign-in failed', err)
// return false
// }
//})()
//export { supaReady }
//export default supa

View File

@@ -0,0 +1,34 @@
// /components/supadb/aksupainstance.uts
import { createClient } from './aksupa.uts'
import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts'
// 创建单一真实的 Supabase 客户端实例 (使用 config.uts 配置)
// Create single source of truth client using config
const supaInstance = createClient(SUPA_URL, SUPA_KEY)
// 导出默认实例 (供 login.uvue 等使用)
export default supaInstance
// 导出命名实例 'supabase' (供 store.uts 使用)
export const supabase = supaInstance
// 导出 isSupabaseReady 状态
export const isSupabaseReady = true
// 兼容 ensureSupabaseReady
export async function ensureSupabaseReady() {
return true
}
// 检查连接状态的函数
export function checkConnection() {
return Promise.resolve(true)
}
// 兼容 supaReady Promise
export const supaReady = Promise.resolve(true)
// 如果有其他需要导出的函数,可以这样导出:
export function initializeSupabase(url: string, key: string) {
return createClient(url, key)
}

View File

@@ -0,0 +1,277 @@
// Postgres 变更订阅参数类型(强类型导出,便于 UTS Android 复用)
export type PostgresChangesSubscribeParams = {
event : string;
schema : string;
table : string;
filter ?: string;
topic ?: string;
onChange : (payload : any) => void;
};
type PostgresChangeListener = {
topic : string;
event : string;
schema : string;
table : string;
filter : string | null;
onChange : (payload : any) => void;
};
export type AkSupaRealtimeOptions = {
url : string; // ws/wss 地址
channel : string; // 订阅频道
token ?: string; // 可选鉴权token
apikey ?: string; // 可选supabase apikey
onMessage : (data : UTSJSONObject) => void;
onOpen ?: (res : any) => void;
onClose ?: (res : any) => void;
onError ?: (err : any) => void;
};
export class AkSupaRealtime {
ws : SocketTask | null = null;
options : AkSupaRealtimeOptions | null = null;
isOpen : boolean = false;
heartbeatTimer : any = 0;
joinedTopics : Set<string> = new Set<string>();
listeners : Array<PostgresChangeListener> = [];
constructor(options : AkSupaRealtimeOptions) {
this.options = options;
}
connect() {
const opts = this.options;
if (opts == null) return;
// 拼接 apikey 和 vsn=1.0.0 到 ws url
let wsUrl = opts.url;
// apikey 兼容 query 已有参数和无参数两种情况
if (opts.apikey != null && opts.apikey !== "") {
const hasQuery = wsUrl.indexOf('?') != -1;
// 移除已有 apikey 参数,避免重复
wsUrl = wsUrl.replace(/([&?])apikey=[^&]*/g, '$1').replace(/[?&]$/, '');
wsUrl += (hasQuery ? '&' : '?') + 'apikey=' + encodeURIComponent('' + opts.apikey);
}
if (wsUrl.indexOf('vsn=') == -1) {
wsUrl += (wsUrl.indexOf('?') == -1 ? '?' : '&') + 'vsn=1.0.0';
}
this.ws = uni.connectSocket({
url: wsUrl,
success: (res) => { console.log(res); },
fail: (err) => { if (opts.onError != null) opts.onError?.(err); }
});
if (this.ws != null) {
const wsTask = this.ws;
wsTask?.onOpen((result : OnSocketOpenCallbackResult) => {
this.isOpen = true;
console.log('onopen', result)
if (opts.onOpen != null) opts.onOpen?.(result);
// 启动 heartbeat 定时器
this.startHeartbeat();
});
wsTask?.onMessage((msg) => {
console.log(msg)
let data : UTSJSONObject | null = null;
try {
const msgData = (typeof msg == 'object' && msg.data !== null) ? msg.data : msg;
data = typeof msgData == 'string' ? JSON.parse(msgData) as UTSJSONObject : msgData as UTSJSONObject;
} catch (e) { }
// 处理 pong
if (
data != null &&
data.event == 'phx_reply' &&
typeof data.payload == 'object' &&
data.payload != null &&
(data.payload as UTSJSONObject).status != null &&
(data.payload as UTSJSONObject).status == 'ok' &&
(data.payload as UTSJSONObject).response != null &&
(data.payload as UTSJSONObject).response == 'heartbeat'
) {
// 收到 pong可用于续约
// 可选:重置定时器
}
console.log(data)
if (data != null) this.dispatchPostgresChange(data);
if (opts?.onMessage != null) opts.onMessage?.(data ?? ({} as UTSJSONObject));
});
wsTask?.onClose((res) => {
console.log('onclose', res)
this.isOpen = false;
this.joinedTopics.clear();
this.listeners = [];
if (opts.onClose != null) opts.onClose?.(res);
this.stopHeartbeat();
});
wsTask?.onError((err) => {
console.log(err)
if (opts.onError != null) opts.onError?.(err);
this.stopHeartbeat();
});
}
}
send(options : SendSocketMessageOptions) {
const wsTask = this.ws;
if (wsTask != null && this.isOpen) {
console.log('send:', options)
// 兼容 uni-app-x send API支持 success/fail 回调
// 只允许 SendSocketMessageOptions 类型,避免 UTSJSONObject 混用
let sendData : any = options.data;
// 若 data 不是字符串,自动序列化
if (typeof sendData !== 'string') {
sendData = JSON.stringify(sendData);
}
options.success ?? ((res) => {
if (typeof options.success == 'function') options.success?.(res)
})
options.fail ?? ((err : any) => {
console.log(err)
const opts = this.options;
if (opts != null && opts.onError != null) opts.onError?.(err);
})
wsTask.send(options);
}
}
close(options : CloseSocketOptions) {
this.ws?.close(options);
}
/**
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
*/
/**
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
*/
/**
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
*/
/**
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
*/
/**
* 订阅 Postgres 变更事件(如 INSERT/UPDATE/DELETE
* @param params { event: 'INSERT'|'UPDATE'|'DELETE', schema: string, table: string, onChange: (payload: any) => void }
*/
public subscribePostgresChanges(params : PostgresChangesSubscribeParams) : void {
const opts = this.options;
if (this.isOpen !== true || opts == null) {
throw new Error('WebSocket 未连接');
}
const topic = params.topic != null && params.topic !== '' ? params.topic : `realtime:${params.schema}:${params.table}`;
this.joinTopicIfNeeded(topic, params);
this.listeners.push({
topic: topic,
event: params.event,
schema: params.schema,
table: params.table,
filter: params.filter != null ? params.filter : null,
onChange: params.onChange
});
}
startHeartbeat() {
this.stopHeartbeat();
console.log('make heartbeat')
// 每 30 秒发送一次 heartbeat官方建议
this.heartbeatTimer = setInterval(() => {
console.log('should startHeartbeat')
if (this.isOpen && this.ws != null) {
const heartbeatMsg = {
topic: 'phoenix',
event: 'heartbeat',
payload: {},
ref: Date.now().toString()
};
this.send({ data: JSON.stringify(heartbeatMsg) });
}
}, 30000);
}
stopHeartbeat() {
console.log('stop heartbeat')
if (typeof this.heartbeatTimer == 'number' && this.heartbeatTimer > 0) {
clearInterval(this.heartbeatTimer as number);
this.heartbeatTimer = 0;
}
}
private joinTopicIfNeeded(topic : string, params : PostgresChangesSubscribeParams) {
if (topic == null || topic == '') return;
if (this.joinedTopics.has(topic)) return;
const changeConfig : any = (params.filter != null && params.filter !== '') ? {
event: params.event,
schema: params.schema,
table: params.table,
filter: params.filter
} : {
event: params.event,
schema: params.schema,
table: params.table
};
const joinMsg = {
event: 'phx_join',
payload: {
config: {
broadcast: { self: false, ack: false },
postgres_changes: [changeConfig],
presence: { key: '', enabled: false },
private: false
},
access_token: this.options != null && this.options.token != null ? this.options.token : null
},
ref: Date.now().toString(),
topic: topic
};
this.send({ data: JSON.stringify(joinMsg) });
this.joinedTopics.add(topic);
}
private dispatchPostgresChange(data : UTSJSONObject) : void {
if (data.event !== 'postgres_changes') return;
const topic = typeof data.topic == 'string' ? data.topic : '';
const payload = data.payload as UTSJSONObject | null;
if (payload == null) return;
const dataSection = payload.get('data') as UTSJSONObject | null;
let payloadEvent = payload.getString('event') as string | null;
if ((payloadEvent == null || payloadEvent == '') && dataSection != null) {
const typeValue = dataSection.getString('type') as string | null;
if (typeValue != null && typeValue !== '') payloadEvent = typeValue;
}
let schemaName = payload.getString('schema') as string | null;
if ((schemaName == null || schemaName == '') && dataSection != null) {
const dataSchema = dataSection.getString('schema') as string | null;
if (dataSchema != null && dataSchema !== '') schemaName = dataSchema;
}
let tableName = payload.getString('table') as string | null;
if ((tableName == null || tableName == '') && dataSection != null) {
const dataTable = dataSection.getString('table') as string | null;
if (dataTable != null && dataTable !== '') tableName = dataTable;
}
const filterValue = payload.getString('filter') as string | null;
for (let i = 0; i < this.listeners.length; i++) {
const listener = this.listeners[i];
if (listener.topic !== topic) continue;
if (listener.event !== '*' && payloadEvent != null && listener.event !== payloadEvent) continue;
if (schemaName != null && listener.schema !== schemaName) continue;
if (tableName != null && listener.table !== tableName) continue;
if (
listener.filter != null && listener.filter !== '' &&
filterValue != null && listener.filter !== filterValue
) continue;
if (typeof listener.onChange == 'function') {
const changeData = dataSection != null ? dataSection : payload;
listener.onChange(changeData);
}
}
}
}
export default AkSupaRealtime;

View File

@@ -0,0 +1,22 @@
# SupaDB 文档更新记录
## 2026-01-30
### AkSupa从本地持久化 token 恢复 user/session
- **文件**`components/supadb/aksupa.uts`
- **位置**`export class AkSupa` -> `constructor(baseUrl: string, apikey: string)`,以及新增方法 `hydrateSessionFromStorage()`
- **定位标记**:在代码中搜索 `// [CHANGE][2026-01-30]`
#### 修改了什么
-`AkSupa` 构造时,会尝试基于本地已持久化的 token`AkReq.setToken` 写入 storage恢复登录态。
- 新增 `hydrateSessionFromStorage()`
- 通过 `AkReq.getToken()` 读取本地 access token
- 若 token 存在,则请求 `GET {baseUrl}/auth/v1/user`
- 将返回的 user 写入 `this.user`
-`this.session` 为空,则补齐一个最小 session 对象,使 `getSession()` 在「重启 App / 刷新页面」后仍能正确反映登录状态
#### 为什么要改
此前 `AkReq` 会把 token 持久化到本地,但 `AkSupa` 启动时不会自动恢复 `this.user` / `this.session`,导致即使 token 仍有效,`getSession()` 也可能返回 `{ session: null, user: null }`,从而使依赖登录态的页面判断失败。

View File

@@ -0,0 +1,155 @@
# AkSupa 简化API指南
## 概述
AkSupa 现在采用了**简化的单一方法**设计:只提供 `executeAs<T>()` 方法进行类型安全的数据访问,移除了所有冗余的类型转换方法。
## 重要变化
### 🚫 已移除的方法
- `selectAs<T>()`
- `insertAs<T>()`
- `updateAs<T>()`
- `deleteAs<T>()`
- `rpcAs<T>()`
### ✅ 统一的方法
- `executeAs<T>()` - 唯一的类型转换方法
## 设计理念
### 简洁性原则
- **一个方法解决所有问题**:所有查询操作最终都要调用 `execute()``executeAs<T>()` 是其类型安全版本
- **减少API复杂性**:不需要记住多个不同的方法名
- **保持一致性**:无论是查询、插入、更新还是删除,都使用相同的方法
### 链式友好
```typescript
// 所有操作都遵循相同的模式
const result = await supa
.from('table')
.operation() // select(), insert(), update(), delete(), rpc()
.conditions() // eq(), gt(), like(), etc.
.executeAs<T>(); // 统一的类型转换方法
```
## 使用示例
### 查询数据
```typescript
// 多条记录
const users = await supa
.from('users')
.select('*')
.eq('status', 'active')
.executeAs<User[]>();
// 单条记录
const user = await supa
.from('users')
.select('*')
.eq('id', 1)
.single()
.executeAs<User>();
```
### 插入数据
```typescript
const newUser = await supa
.from('users')
.insert({
name: 'John',
email: 'john@example.com'
})
.executeAs<User>();
```
### 更新数据
```typescript
const updatedUser = await supa
.from('users')
.update({ name: 'Jane' })
.eq('id', 1)
.executeAs<User>();
```
### 删除数据
```typescript
const deletedUser = await supa
.from('users')
.delete()
.eq('id', 1)
.executeAs<User>();
```
### RPC调用
```typescript
const result = await supa
.from('any_table')
.rpc('my_function', { param1: 'value1' })
.executeAs<ResultType>();
```
## 平台兼容性
| 平台 | 类型转换机制 | 说明 |
|------|-------------|------|
| Android | `UTSJSONObject.parse<T>()` | 真正的类型转换 |
| HarmonyOS | `UTSJSONObject.parse<T>()` | 真正的类型转换 |
| Web/iOS | `as T` | 类型断言 |
## 从旧版本迁移
### 旧代码
```typescript
// 旧方式 - 多个方法
const users = await supa.selectAs<User[]>('users', null, { limit: 10 });
const newUser = await supa.insertAs<User>('users', userData);
const updated = await supa.updateAs<User>('users', filter, updateData);
```
### 新代码
```typescript
// 新方式 - 统一方法
const users = await supa
.from('users')
.select('*')
.limit(10)
.executeAs<User[]>();
const newUser = await supa
.from('users')
.insert(userData)
.executeAs<User>();
const updated = await supa
.from('users')
.update(updateData)
.eq('id', userId)
.executeAs<User>();
```
## 优势
1. **API简洁**:只需要记住一个方法
2. **类型安全**TypeScript 编译时检查
3. **平台兼容**Android/HarmonyOS 使用真正的类型转换
4. **链式友好**:与现有的链式方法无缝集成
5. **维护性强**:单一方法,减少维护成本
## 注意事项
1. **泛型类型**:确保传入正确的类型参数 `<T>`
2. **错误处理**:检查 `result.error``result.data` 的有效性
3. **性能考虑**Android 平台的类型转换有轻微性能开销
4. **调试模式**:开发时会有转换过程的控制台输出
## 总结
通过采用单一的 `executeAs<T>()` 方法AkSupa 现在提供了:
- 更简洁的API
- 更好的类型安全
- 更一致的使用体验
- 更容易维护的代码
这个设计遵循了"简单就是美"的原则让开发者能够更专注于业务逻辑而不是API的复杂性。

View File

@@ -0,0 +1,194 @@
# AkSupa executeAs<T>() 类型转换功能
## 概述
AkSupa 现在提供简洁的 `executeAs<T>()` 方法,支持链式请求的类型转换功能,可以直接返回指定的类型而不仅仅是 `UTSJSONObject`
## 设计理念
遵循 **简洁性原则**,只提供一个 `executeAs<T>()` 方法来处理所有类型转换需求,因为:
1. **统一API**:所有操作最终都通过 `execute()` 处理,`executeAs<T>()` 是其类型安全版本
2. **链式友好**:可以与所有现有的链式方法无缝组合
3. **易于理解**:只需记住一个方法,降低学习成本
4. **功能完整**覆盖查询、插入、更新、删除、RPC 等所有操作
## 平台兼容性
| 平台 | 支持方式 | 说明 |
|------|----------|------|
| Android (uni-app x 3.90+) | `UTSJSONObject.parse()` | 真正的类型转换 |
| Web | `as T` | 类型断言,编译时类型提示 |
| iOS | `as T` | 类型断言,编译时类型提示 |
| HarmonyOS (4.61+) | `UTSJSONObject.parse()` | 真正的类型转换 |
## 方法签名
```typescript
async executeAs<T>() : Promise<AkReqResponse<T>>
```
## 使用示例
### 1. 定义数据类型
```typescript
export type User = {
id: number;
name: string;
email: string;
created_at: string;
avatar_url?: string;
}
```
### 2. 查询操作
```typescript
// 查询多条记录
const usersResult = await supa
.from('users')
.select('*')
.eq('status', 'active')
.limit(10)
.executeAs<User[]>();
// 查询单条记录
const userResult = await supa
.from('users')
.select('*')
.eq('id', 1)
.single()
.executeAs<User>();
// 复杂查询
const complexQuery = await supa
.from('posts')
.select('*, users!posts_user_id_fkey(*)')
.eq('status', 'published')
.gt('created_at', '2024-01-01')
.order('created_at', { ascending: false })
.limit(20)
.executeAs<Post[]>();
```
### 3. 插入操作
```typescript
const newUser = {
name: '新用户',
email: 'newuser@example.com'
} as UTSJSONObject;
const insertResult = await supa
.from('users')
.insert(newUser)
.executeAs<User[]>();
```
### 4. 更新操作
```typescript
const updateResult = await supa
.from('users')
.update({ name: '更新的名称' } as UTSJSONObject)
.eq('id', 1)
.executeAs<User[]>();
```
### 5. 删除操作
```typescript
const deleteResult = await supa
.from('users')
.delete()
.eq('id', 1)
.executeAs<User[]>();
```
### 6. RPC 调用
```typescript
const rpcResult = await supa
.from('') // RPC 不需要 table
.rpc('get_user_stats', { user_id: 1 } as UTSJSONObject)
.executeAs<{ total_posts: number; total_likes: number }>();
```
## 错误处理
```typescript
try {
const result = await supa
.from('users')
.select('*')
.executeAs<User[]>();
if (result.error) {
console.error('查询失败:', result.error);
return;
}
// 使用类型化的数据
const users = result.data;
if (users != null) {
users.forEach(user => {
// 现在有完整的类型提示
console.log(user.name, user.email);
});
}
} catch (error) {
console.error('请求异常:', error);
}
```
## 向后兼容性
- 原有的 `execute()` 方法依然保持不变,返回 `UTSJSONObject`
- 所有原有的链式方法都继续正常工作
- `executeAs<T>()` 是附加功能,不影响现有代码
## 对比旧版本
### 旧版本(多方法)
```typescript
// 需要记住多个方法
const users = await supa.selectAs<User[]>('users', filter, options);
const inserted = await supa.insertAs<User>('users', data);
const updated = await supa.updateAs<User[]>('users', filter, values);
const deleted = await supa.deleteAs<User[]>('users', filter);
const rpcResult = await supa.rpcAs<Stats>('func_name', params);
```
### 新版本(统一方法)
```typescript
// 只需记住一个 executeAs<T>() 方法
const users = await supa.from('users').select('*').executeAs<User[]>();
const inserted = await supa.from('users').insert(data).executeAs<User>();
const updated = await supa.from('users').update(values).eq('id', 1).executeAs<User[]>();
const deleted = await supa.from('users').delete().eq('id', 1).executeAs<User[]>();
const rpcResult = await supa.from('').rpc('func_name', params).executeAs<Stats>();
```
## 优势
1. **API 简洁**:只需要记住一个 `executeAs<T>()` 方法
2. **链式友好**:与所有现有方法完美组合
3. **类型安全**:编译时检查 + 运行时转换Android
4. **易于维护**:减少重复代码,统一处理逻辑
5. **学习成本低**:从 `execute()``executeAs<T>()` 自然过渡
## 注意事项
1. `UTSJSONObject.parse()` 仅在 Android 3.90+ 和 HarmonyOS 4.61+ 平台支持
2. 其他平台使用类型断言,主要提供编译时类型检查
3. 类型转换失败时会 fallback 到原始数据
4. 建议在生产环境中进行充分的测试
## 技术实现
`executeAs<T>()` 内部:
1. 调用原有的 `execute()` 方法获取结果
2. 在 Android 平台使用 `UTSJSONObject.parse()` 进行类型转换
3. 在其他平台使用类型断言提供类型提示
4. 返回类型化的 `AkReqResponse<T>` 结果

View File

@@ -0,0 +1,126 @@
# AkSupa 类型转换错误修复总结
## 问题描述
在实现 `executeAs<T>()` 和相关类型转换方法时,遇到了以下 UTS 编译错误:
1. **泛型类型参数错误**`Cannot use 'T' as reified type parameter`
2. **类型推断错误**`推断类型是T?可为空的T但预期的是Any`
3. **方法参数错误**`Too many arguments for public open fun select`
4. **属性访问错误**`Unresolved reference: data`
## 修复方案
### 1. 移除泛型类型参数
**问题**UTS 不支持 `UTSJSONObject.parse<T>()` 这种带泛型参数的调用方式。
**解决方案**
-`item.parse<T>()` 改为 `item.parse()`
-`result.data.parse<T>()` 改为 `result.data.parse()`
- 使用类型断言 `as T` 来提供类型提示
```typescript
// 修复前
convertedData = result.data.parse<T>();
// 修复后
convertedData = result.data.parse();
```
### 2. 简化方法签名
**问题**`_convertResponse<T>()` 方法的泛型签名在 UTS 中无法正确处理。
**解决方案**
-`_convertResponse<T>()` 改为 `_convertResponse()`
- 返回类型改为 `AkReqResponse<any>`
- 在调用处使用类型断言 `as AkReqResponse<T>`
```typescript
// 修复前
private _convertResponse<T>(result: AkReqResponse<any>): AkReqResponse<T>
// 修复后
private _convertResponse(result: AkReqResponse<any>): AkReqResponse<any>
```
### 3. 统一类型处理
**问题**:不同平台的类型处理逻辑不一致。
**解决方案**
- Android 平台:使用 `UTSJSONObject.parse()` 进行真正的类型转换
- 其他平台:直接返回原始结果,通过类型断言提供类型提示
```typescript
// Android 平台
convertedData = result.data.parse();
// 其他平台
return result; // 直接返回原始结果
```
### 4. 空值处理优化
**问题**`parse()` 方法可能返回 `null`,需要安全处理。
**解决方案**
- 增加 `null` 检查:`parsed != null ? parsed : item`
- 保持原始数据作为 fallback
```typescript
const parsed = item.parse();
convertedArray.push(parsed != null ? parsed : item);
```
## 修复的方法列表
### AkSupaQueryBuilder 类
-`executeAs<T>()` - 链式查询的类型转换执行
### AkSupa 类
-`selectAs<T>()` - 查询并类型转换
-`insertAs<T>()` - 插入并类型转换
-`updateAs<T>()` - 更新并类型转换
-`deleteAs<T>()` - 删除并类型转换
-`rpcAs<T>()` - RPC调用并类型转换
-`_convertResponse()` - 私有类型转换方法
## 平台兼容性
| 平台 | 处理方式 | 效果 |
|------|----------|------|
| Android | `UTSJSONObject.parse()` | 真正的类型转换 |
| iOS | 类型断言 `as T` | 编译时类型检查 |
| Web | 类型断言 `as T` | 编译时类型检查 |
| HarmonyOS | `UTSJSONObject.parse()` | 真正的类型转换 |
## 使用示例
```typescript
// 现在可以正常使用了
const users = await supa
.from('users')
.select('*')
.executeAs<User[]>();
// 类型安全的访问
users.data?.forEach(user => {
console.log(user.name); // 有完整的类型提示
});
// 直接方法调用
const result = await supa.selectAs<User[]>('users');
```
## 技术要点
1. **UTS 限制**:不支持泛型的 reified 类型参数
2. **类型安全**:通过编译时类型断言提供类型提示
3. **运行时转换**:在支持的平台上进行真正的类型转换
4. **向后兼容**:原有的 `.execute()` 方法保持不变
## 总结
修复后的代码在保持类型安全的同时,完全兼容 UTS 的编译要求。在 Android 和 HarmonyOS 平台上提供真正的类型转换,在其他平台上提供编译时类型检查,为开发者提供了更好的开发体验。

View File

@@ -0,0 +1,56 @@
GET wss://ak3.oulog.com/realtime/v1/websocket?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q&vsn=1.0.0 HTTP/1.1
Host: ak3.oulog.com
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1
Upgrade: websocket
Origin: http://localhost:5174
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Sec-WebSocket-Key: dJtuVuI1PWGVjC2E/qCDbQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP/1.1 101 Switching Protocols
Server: nginx
Date: Thu, 03 Jul 2025 04:03:55 GMT
Connection: upgrade
cache-control: max-age=0, private, must-revalidate
sec-websocket-accept: XzR5+Z20bTKH4Ytm23KUTpQmDKE=
upgrade: websocket
Access-Control-Allow-Origin: *
X-Kong-Upstream-Latency: 1
X-Kong-Proxy-Latency: 0
Via: kong/2.8.1
GET wss://ak3.oulog.com/realtime/v1/websocket?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q&vsn=1.0.0 HTTP/1.1
Host: ak3.oulog.com
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1
Upgrade: websocket
Origin: http://localhost:5173
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Sec-WebSocket-Key: ZNkWHFYshDAoPrErr9EY9w==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP/1.1 101 Switching Protocols
Server: nginx
Date: Thu, 03 Jul 2025 07:05:31 GMT
Connection: upgrade
cache-control: max-age=0, private, must-revalidate
sec-websocket-accept: SV8HQ/NAJvS8eQcHVMmIdMRWcb4=
upgrade: websocket
Access-Control-Allow-Origin: *
X-Kong-Upstream-Latency: 3
X-Kong-Proxy-Latency: 1
Via: kong/2.8.1

View File

@@ -0,0 +1,36 @@
// const agent_id = "2bb9fa0cbae011efbf780242ac120006";
const agent_id = "15b01b26128111f08cd30242ac120006";
// const agent_id = "9eb32c5395d64ac48752b25efdd3b3bb";
// const requrl = "https://rag.oulog.com/v1/canvas/completion";
const requrl = "https://rag.oulog.com/api/v1/agents_openai/"+agent_id+"/chat/completions";
// let beareaToken = "ImQwODRkOGJlZjI3ZjExZWZhZTZhMDI0MmFjMTIwMDA2Ig.Z7wduA.DEPPVfSZaP2MBKJN8vw14VxOXG0";
import { RAG_API_KEY } from "@/ak/config";
let beareaToken = RAG_API_KEY
export function requestCanvasCompletion(question) {
const new_uuid = `${Date.now()}${Math.floor(Math.random() * 1e7)}`
const messages = [{"role": "user", "content": question}]
return new Promise((resolve, reject) => {
uni.request({
url: requrl,
method: "POST",
data: {
id: agent_id,
messages: messages,
stream: false,
model:"deepseek-r1",
message_id: new_uuid,
},
header: {
"content-Type": "application/json",
Authorization: 'Bearer '+beareaToken,
},
success: (res) => {
resolve(res.data);
},
fail: (err) => {
reject(err);
},
});
});
}

View File

@@ -0,0 +1,7 @@
import { RagReq,RagReqConfig } from '@/uni_modules/rag-req/rag-req.uts'
import { RAG_BASE_URL, RAG_API_KEY } from '@/ak/config.uts'
const ragconfig = { baseUrl: RAG_BASE_URL, apiKey: RAG_API_KEY } as RagReqConfig
const rag = new RagReq(ragconfig)
export default rag

View File

@@ -0,0 +1,364 @@
<template>
<slot name="default" :data="data" :current="localPageCurrent" :total="total" :hasmore="hasmore" :loading="loading" :error="error">
</slot>
</template>
<script setup lang="uts"> import { ref, watch, onMounted } from 'vue';
import supa from './aksupainstance.uts';
import { AkSupaSelectOptions } from './aksupa.uts'
import { AkReqResponse } from '@/uni_modules/ak-req/index.uts';
import { toUniError } from '@/utils/utils.uts';
const props = defineProps({
collection: {
type: String,
default: ''
},
filter: {
type: UTSJSONObject,
default: () => ({}),
},
field: {
type: String,
default: '*'
},
where: Object,
orderby: String,
pageData: {
type: String,
default: 'add',
},
pageCurrent: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 10,
},
/*"exact" | "planned" | "estimated" */
getcount: {
type: String,
default: '',
},
getone: {
type: Boolean,
default: false,
},
loadtime: {
type: String,
default: 'auto',
},
datafunc: Function,
// RPC 函数名,当使用 RPC 时collection 参数可以为空
rpc: {
type: String,
default: ''
},
// RPC 参数,用于传递给 RPC 函数的额外参数
params: {
type: UTSJSONObject,
default: () => ({})
}
});
const emit = defineEmits<{
(e : 'process-data', val : UTSJSONObject) : void,
(e : 'load', val : any[]) : void,
(e : 'error', val : any) : void
}>();
const data = ref<any[]>([]);
const loading = ref(false);
const error = ref<any>('');
const total = ref(0);
const hasmore = ref(true);
// Use local refs for pagination state to avoid mutating props directly
const localPageCurrent = ref(props.pageCurrent);
const localPageSize = ref(props.pageSize);
type Pagination = {
count : Number;
current : Number;
size : Number;
};
let pagination = { total: 0 };
let hasMoreData = true;
/**
* Unified data loading method
* @param {UTSJSONObject} opt
* opt.append Whether to append data
* opt.clear Whether to clear data
* opt.page Specify page number
*/ const fetchData = async (opt : UTSJSONObject) => {
loading.value = true;
error.value = '';
// 检查是否为 RPC 调用
const isRpcCall = props.rpc != null && props.rpc.length > 0;
// 只有在非 RPC 调用时才检查 collection
if (!isRpcCall && (props.collection == null || props.collection.trim() == '')) {
error.value = 'collection/table 不能为空';
loading.value = false;
return;
}
// RPC 调用时检查 rpc 参数
if (isRpcCall && (props.rpc == null || props.rpc.trim() == '')) {
error.value = 'rpc 函数名不能为空';
loading.value = false;
return;
}try {
// Platform-specific parameter extraction for UTSJSONObject compatibility
let append: boolean = false
let clear: boolean = false
let page: number = localPageCurrent.value
// #ifdef APP-ANDROID || APP-IOS
// Native platform: use UTSJSONObject methods
append = opt.getBoolean('append') ?? false
clear = opt.getBoolean('clear') ?? false
page = opt.getNumber('page') ?? localPageCurrent.value
// #endif
// #ifndef APP-ANDROID || APP-IOS
// Web platform: direct property access
append = (opt as any)['append'] as boolean ?? false
clear = (opt as any)['clear'] as boolean ?? false
page = (opt as any)['page'] as number ?? localPageCurrent.value
// #endif
// Update local pagination state
localPageCurrent.value = page;
localPageSize.value = props.pageSize;
// Build query options
let selectOptions : AkSupaSelectOptions = {
limit: localPageSize.value,
order: props.orderby,
columns: props.field,
};
if (props.getcount != null && props.getcount.length > 0) {
selectOptions['getcount'] = props.getcount;
} let result: any;
if (isRpcCall) {
// 支持rpc调用 - RPC方法只接受functionName和params两个参数
// 将filter、params和selectOptions合并为rpcParams
const rpcParams = new UTSJSONObject();
// 首先添加props.params中的参数
if (props.params != null) {
const paramsKeys = UTSJSONObject.keys(props.params);
for (let i = 0; i < paramsKeys.length; i++) {
const key = paramsKeys[i];
// #ifdef APP-ANDROID || APP-IOS
// Native platform: use UTSJSONObject methods
rpcParams.set(key, props.params.get(key));
// #endif
// #ifndef APP-ANDROID || APP-IOS
// Web platform: direct property access
rpcParams.set(key, (props.params as any)[key]);
// #endif
}
}
// 然后添加filter中的参数可能会覆盖params中的同名参数
if (props.filter != null) {
// Platform-specific filter handling for UTSJSONObject compatibility
const filterKeys = UTSJSONObject.keys(props.filter);
for (let i = 0; i < filterKeys.length; i++) {
const key = filterKeys[i];
// #ifdef APP-ANDROID || APP-IOS
// Native platform: use UTSJSONObject methods
rpcParams.set(key, props.filter.get(key));
// #endif
// #ifndef APP-ANDROID || APP-IOS
// Web platform: direct property access
rpcParams.set(key, (props.filter as any)[key]);
// #endif
}
}
// 添加分页和排序参数
if (selectOptions.limit != null) rpcParams.set('limit', selectOptions.limit);
if (selectOptions.order != null) rpcParams.set('order', selectOptions.order);
if (selectOptions.columns != null) rpcParams.set('columns', selectOptions.columns);
if (selectOptions.getcount != null) rpcParams.set('getcount', selectOptions.getcount);
result = await supa.rpc(props.rpc, rpcParams);
} else {
// Query data
result = await supa.select_uts(props.collection, props.filter, selectOptions);
}
// headers 判空
let countstring = '';
let headers:UTSJSONObject = result.headers != null ? result.headers : {};
if (headers != null) {
if (typeof headers.getString == 'function') {
let val = headers.getString('content-range');
if (val != null && typeof val == 'string') {
countstring = val;
}
} else if (headers['content-range'] != null) {
// 类型断言为 string否则转为 string
countstring = `${headers['content-range']}`;
}
}
console.log(countstring)
if (countstring != null && countstring != '') {
try {
const rangeParts = countstring.split('/')
if (rangeParts.length == 2) {
// 检查第二部分是否为数字(不是 '*'
const totalPart = rangeParts[1].trim()
if (totalPart !== '*' && !isNaN(parseInt(totalPart))) {
total.value = parseInt(totalPart)
console.log('Total count from header:', total.value)
pagination.total = total.value;
const rangeValues = rangeParts[0].split('-')
if (rangeValues.length == 2) {
const end = parseInt(rangeValues[1])
hasmore.value = end < total.value - 1
hasMoreData = hasmore.value
} } else {
// 当总数未知时(返回 *),设置默认值
console.log('Total count unknown (*), using default pagination logic')
total.value = 0
pagination.total = 0
// 根据当前返回的数据量判断是否还有更多数据
hasmore.value = Array.isArray(result.data) && (result.data as any[]).length >= localPageSize.value
hasMoreData = hasmore.value
}
}
} catch (e) {
console.error('Failed to parse content-range header', e)
} } else {
// 当没有 content-range header 时,根据返回的数据量判断分页
console.log('No content-range header, using data length for pagination')
total.value = 0
pagination.total = 0
// 如果返回的数据量等于页面大小,可能还有更多数据
hasmore.value = Array.isArray(result.data) && (result.data as any[]).length >= localPageSize.value
hasMoreData = hasmore.value
}
// data 判空
// UTS 平台下无 UTSArray.fromAny需兼容
let items: Array<any> = (Array.isArray(result.data)) ? result.data as Array<any> : Array<any>();
try {
// emit('process-data', items != null ? items : []);
console.log(result)
// Manually create UTSJSONObject from AkReqResponse for cross-platform compatibility
const prodata = new UTSJSONObject()
prodata.set('status', result.status)
prodata.set('data', result.data)
prodata.set('headers', result.headers)
prodata.set('error', result.error)
prodata.set('total', total.value)
emit('process-data', prodata);
} catch (e) {
console.error('emit process-data error', e);
}
if (clear) {
data.value = [];
}
if (append) {
if(Array.isArray(items)) {
data.value = ([] as any[]).concat(data.value, items);
}
} else {
data.value = items as any[];
}
pagination.total = total.value;
hasMoreData = Array.isArray(items) && items.length == localPageSize.value; } catch (err : any) {
// 使用标准化错误处理
const uniError = toUniError(err, 'An error occurred while fetching data')
try {
emit('error', uniError);
} catch (e) {
console.error('emit error event error', e);
}
} finally {
loading.value = false;
}
};
// 页码变更统一通过 fetchData
const handlePageChange = (page : number) => {
fetchData({ page: page, clear: true, append: false } as UTSJSONObject);
};
// 主动加载数据,支持清空
const loadData = (opt : UTSJSONObject) => {
console.log('loadData')
// Platform-specific parameter extraction for UTSJSONObject compatibility
let clear: boolean = true
// #ifdef APP-ANDROID || APP-IOS
// Native platform: use UTSJSONObject methods
clear = opt.getBoolean('clear') ?? true
// #endif
// #ifndef APP-ANDROID || APP-IOS
// Web platform: direct property access
clear = (opt as any)['clear'] as boolean ?? true
// #endif
fetchData({ clear, append: false, page: 1 } as UTSJSONObject);
};
const refresh = () => {
console.log('refresh')
const clear = true;
fetchData({ clear, append: false, page: 1 } as UTSJSONObject);
};
// 加载更多,自动追加
const loadMore = () => {
if (hasMoreData) {
const nextPage = props.pageCurrent + 1;
fetchData({ append: true, clear: false, page: nextPage } as UTSJSONObject);
}
};
if (props.loadtime === 'auto' || props.loadtime === 'onready') {
onMounted(() => {
fetchData({} as UTSJSONObject);
});
}
// watch(data, (newValue : any) => {
// emit('load', newValue);
// });
watch(error, (newValue : any) => {
if (newValue != null) {
emit('error', newValue);
}
});
// Documented exposed methods for parent components
/**
* Exposed methods:
* - loadData(opt?): Load data, optionally clearing previous data
* - loadMore(): Load next page and append
*/
defineExpose({ loadData,refresh, loadMore,hasmore,total });
</script>
<style scoped>
/* 添加您的样式 */
</style>

View File

@@ -0,0 +1,122 @@
// 示例:如何使用 AkSupa 的 executeAs<T>() 类型转换功能
// 定义数据类型
export type User = {
id: number;
name: string;
email: string;
created_at: string;
avatar_url?: string;
}
export type Post = {
id: number;
title: string;
content: string;
user_id: number;
created_at: string;
updated_at: string;
}
// 使用示例
import AkSupa from '@/components/supadb/aksupa.uts';
export async function demonstrateTypedQueries() {
const supa = new AkSupa('https://your-project.supabase.co', 'your-anon-key');
// 1. 查询数据 - 使用链式调用 + executeAs<T>()
const usersResult = await supa
.from('users')
.select('*')
.eq('status', 'active')
.limit(10)
.executeAs<User[]>();
// 现在 usersResult.data 是 User[] 类型,而不是 UTSJSONObject
if (usersResult.data != null) {
usersResult.data.forEach(user => {
console.log(`用户: ${user.name}, 邮箱: ${user.email}`);
});
}
// 2. 单条记录查询
const userResult = await supa
.from('users')
.select('*')
.eq('id', 1)
.single()
.executeAs<User>();
if (userResult.data != null) {
console.log(`用户名: ${userResult.data.name}`);
}
// 3. 插入数据
const newUser = {
name: '新用户',
email: 'newuser@example.com'
} as UTSJSONObject;
const insertResult = await supa
.from('users')
.insert(newUser)
.executeAs<User[]>();
// 4. 更新数据
const updateResult = await supa
.from('users')
.update({ name: '更新的名称' } as UTSJSONObject)
.eq('id', 1)
.executeAs<User[]>();
// 5. 删除数据
const deleteResult = await supa
.from('users')
.delete()
.eq('id', 1)
.executeAs<User[]>();
// 6. RPC 调用
const rpcResult = await supa
.from('') // RPC 不需要 table
.rpc('get_user_stats', { user_id: 1 } as UTSJSONObject)
.executeAs<{ total_posts: number; total_likes: number }>();
// 7. 复杂查询示例
const complexQuery = await supa
.from('posts')
.select('*, users!posts_user_id_fkey(*)')
.eq('status', 'published')
.gt('created_at', '2024-01-01')
.order('created_at', { ascending: false })
.limit(20)
.executeAs<Post[]>();
return {
users: usersResult.data,
user: userResult.data,
newUser: insertResult.data,
updated: updateResult.data,
deleted: deleteResult.data,
stats: rpcResult.data,
posts: complexQuery.data
};
}
// 平台兼容性说明:
//
// Android 平台uni-app x 3.90+
// - 使用 UTSJSONObject.parse() 进行真正的类型转换
// - 数据会被正确解析为指定的类型 T
// - 如果转换失败,会 fallback 到原始数据
//
// 其他平台Web、iOS、HarmonyOS
// - 使用 as 进行类型断言
// - 这只是 TypeScript 编译时的类型提示,运行时仍然是原始数据
// - 但提供了更好的开发体验和类型安全
//
// 使用优势:
// 1. 统一的 API - 只需要记住 executeAs<T>() 一个方法
// 2. 链式调用 - 可以和所有其他方法组合使用
// 3. 类型安全 - 编译时类型检查运行时类型转换Android
// 4. 简洁明了 - 不需要多个 selectAs/insertAs/updateAs 等方法