Initial commit of akmon project
This commit is contained in:
535
components/HealthTimeChart.uvue
Normal file
535
components/HealthTimeChart.uvue
Normal file
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<view class="health-time-chart">
|
||||
<canvas
|
||||
canvas-id="healthTimeChart"
|
||||
class="chart-canvas"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@tap="onTap"
|
||||
></canvas>
|
||||
|
||||
<!-- HUD显示当前值 -->
|
||||
<view v-if="showHud && crosshair.visible" class="chart-hud" :style="hudStyle">
|
||||
<text class="hud-time">{{ crosshair.timeLabel }}</text>
|
||||
<view v-for="metric in crosshair.metrics" :key="metric.type" class="hud-metric">
|
||||
<text class="hud-label" :style="{ color: metric.color }">{{ metric.label }}:</text>
|
||||
<text class="hud-value">{{ metric.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
|
||||
// 类型定义
|
||||
type HealthMetricType = 'heart_rate' | 'spo2' | 'speed'
|
||||
|
||||
interface HealthDataPoint {
|
||||
timestamp: number
|
||||
heart_rate?: number
|
||||
spo2?: number
|
||||
speed?: number
|
||||
}
|
||||
|
||||
interface MetricConfig {
|
||||
type: HealthMetricType
|
||||
label: string
|
||||
color: string
|
||||
enabled: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
interface TimeAxisConfig {
|
||||
format: 'ss' | 'mm:ss' // 时间显示格式
|
||||
interval: 1 | 5 // 点间隔(秒)
|
||||
}
|
||||
|
||||
interface ViewportState {
|
||||
scaleX: number // 每个数据点的像素宽度
|
||||
scrollX: number // 水平滚动偏移
|
||||
minScaleX: number
|
||||
maxScaleX: number
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
data: HealthDataPoint[]
|
||||
metrics: MetricConfig[]
|
||||
timeConfig: TimeAxisConfig
|
||||
width: number
|
||||
height: number
|
||||
showHud?: boolean
|
||||
autoScroll?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showHud: true,
|
||||
autoScroll: true
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'crosshair-change': [data: { index: number; data: HealthDataPoint; metrics: any[] }]
|
||||
'viewport-change': [viewport: ViewportState]
|
||||
}>()
|
||||
|
||||
// 响应式状态
|
||||
const canvasRef = ref<any>(null)
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
const dpr = ref(1)
|
||||
|
||||
const viewport = reactive<ViewportState>({
|
||||
scaleX: 20, // 每个点20像素宽
|
||||
scrollX: 0,
|
||||
minScaleX: 5,
|
||||
maxScaleX: 100
|
||||
})
|
||||
|
||||
const crosshair = reactive({
|
||||
visible: false,
|
||||
index: -1,
|
||||
data: null as HealthDataPoint | null,
|
||||
timeLabel: '',
|
||||
metrics: [] as any[]
|
||||
})
|
||||
|
||||
const axis = reactive({
|
||||
left: 60,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: 40
|
||||
})
|
||||
|
||||
// HUD位置
|
||||
const hudPos = reactive({ x: 8, y: 8 })
|
||||
|
||||
// 触摸状态
|
||||
const pointer = reactive({
|
||||
pressed: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
lastX: 0,
|
||||
lastY: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const chartWidth = computed(() => props.width - axis.left - axis.right)
|
||||
const chartHeight = computed(() => props.height - axis.top - axis.bottom)
|
||||
|
||||
const hudStyle = computed(() => ({
|
||||
left: `${hudPos.x}px`,
|
||||
top: `${hudPos.y}px`
|
||||
}))
|
||||
|
||||
// 工具函数
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function formatTime(seconds: number, format: 'ss' | 'mm:ss'): string {
|
||||
if (format === 'ss') {
|
||||
return seconds.toString().padStart(2, '0')
|
||||
} else {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
function indexToX(index: number): number {
|
||||
return axis.left + index * viewport.scaleX + viewport.scrollX
|
||||
}
|
||||
|
||||
function xToIndex(x: number): number {
|
||||
return Math.round((x - axis.left - viewport.scrollX) / viewport.scaleX)
|
||||
}
|
||||
|
||||
function toY(value: number, metric: MetricConfig): number {
|
||||
const range = (metric.max || 200) - (metric.min || 0)
|
||||
const normalized = (value - (metric.min || 0)) / range
|
||||
return axis.top + chartHeight.value * (1 - normalized)
|
||||
}
|
||||
|
||||
// 渲染函数
|
||||
function clearAll() {
|
||||
if (!ctx.value) return
|
||||
ctx.value.clearRect(0, 0, props.width, props.height)
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
if (!ctx.value) return
|
||||
const w = chartWidth.value
|
||||
const h = chartHeight.value
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.strokeStyle = '#E0E0E0'
|
||||
ctx.value.lineWidth = 1
|
||||
|
||||
// 垂直网格线(时间轴)
|
||||
const dataLength = props.data.length
|
||||
for (let i = 0; i <= dataLength; i += 10) { // 每10个点画一条线
|
||||
const x = indexToX(i)
|
||||
if (x >= axis.left && x <= axis.left + w) {
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, axis.top)
|
||||
ctx.value.lineTo(x, axis.top + h)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// 水平网格线(指标值)
|
||||
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
||||
enabledMetrics.forEach(metric => {
|
||||
const range = (metric.max || 200) - (metric.min || 0)
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = (metric.min || 0) + (range * i) / 5
|
||||
const y = toY(value, metric)
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(axis.left, y)
|
||||
ctx.value.lineTo(axis.left + w, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
})
|
||||
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
if (!ctx.value || !props.data.length) return
|
||||
|
||||
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
||||
enabledMetrics.forEach(metric => {
|
||||
renderMetricLine(metric)
|
||||
})
|
||||
}
|
||||
|
||||
function renderMetricLine(metric: MetricConfig) {
|
||||
if (!ctx.value) return
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.strokeStyle = metric.color
|
||||
ctx.value.lineWidth = 2
|
||||
ctx.value.beginPath()
|
||||
|
||||
let hasPoints = false
|
||||
props.data.forEach((point, index) => {
|
||||
const value = point[metric.type]
|
||||
if (value != null) {
|
||||
const x = indexToX(index)
|
||||
const y = toY(value, metric)
|
||||
if (!hasPoints) {
|
||||
ctx.value.moveTo(x, y)
|
||||
hasPoints = true
|
||||
} else {
|
||||
ctx.value.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (hasPoints) {
|
||||
ctx.value.stroke()
|
||||
}
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
function renderTimeAxis() {
|
||||
if (!ctx.value || !props.data.length) return
|
||||
|
||||
const w = chartWidth.value
|
||||
const dataLength = props.data.length
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.fillStyle = '#666'
|
||||
ctx.value.font = '12px sans-serif'
|
||||
ctx.value.textAlign = 'center'
|
||||
ctx.value.textBaseline = 'top'
|
||||
|
||||
// 计算可见范围
|
||||
const startIndex = Math.max(0, Math.floor(-viewport.scrollX / viewport.scaleX))
|
||||
const endIndex = Math.min(dataLength - 1, startIndex + Math.ceil(w / viewport.scaleX))
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i += 10) { // 每10个点显示一个时间标签
|
||||
if (i < dataLength) {
|
||||
const x = indexToX(i)
|
||||
const seconds = i * props.timeConfig.interval
|
||||
const label = formatTime(seconds, props.timeConfig.format)
|
||||
ctx.value.fillText(label, x, axis.top + chartHeight.value + 4)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
function renderValueAxis() {
|
||||
if (!ctx.value) return
|
||||
|
||||
const h = chartHeight.value
|
||||
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.fillStyle = '#666'
|
||||
ctx.value.font = '12px sans-serif'
|
||||
ctx.value.textAlign = 'right'
|
||||
ctx.value.textBaseline = 'middle'
|
||||
|
||||
enabledMetrics.forEach(metric => {
|
||||
const range = (metric.max || 200) - (metric.min || 0)
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = (metric.min || 0) + (range * i) / 5
|
||||
const y = toY(value, metric)
|
||||
const label = value.toFixed(metric.type === 'speed' ? 1 : 0)
|
||||
ctx.value.fillText(label, axis.left - 4, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
function renderCrosshair() {
|
||||
if (!ctx.value || !crosshair.visible || crosshair.index < 0) return
|
||||
|
||||
const x = indexToX(crosshair.index)
|
||||
const w = chartWidth.value
|
||||
const h = chartHeight.value
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.strokeStyle = '#999'
|
||||
ctx.value.setLineDash([4, 4])
|
||||
ctx.value.lineWidth = 1
|
||||
|
||||
// 垂直线
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, axis.top)
|
||||
ctx.value.lineTo(x, axis.top + h)
|
||||
ctx.value.stroke()
|
||||
|
||||
// 水平线(每个指标)
|
||||
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
||||
enabledMetrics.forEach(metric => {
|
||||
const point = props.data[crosshair.index]
|
||||
if (point && point[metric.type] != null) {
|
||||
const y = toY(point[metric.type]!, metric)
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(axis.left, y)
|
||||
ctx.value.lineTo(axis.left + w, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
})
|
||||
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
function render() {
|
||||
clearAll()
|
||||
renderGrid()
|
||||
renderMetrics()
|
||||
renderTimeAxis()
|
||||
renderValueAxis()
|
||||
renderCrosshair()
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function onTouchStart(e: any) {
|
||||
const touches = e.touches || []
|
||||
if (touches.length > 0) {
|
||||
const t = touches[0]
|
||||
pointer.pressed = true
|
||||
pointer.x = pointer.lastX = t.x
|
||||
pointer.y = pointer.lastY = t.y
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(e: any) {
|
||||
const touches = e.touches || []
|
||||
if (!pointer.pressed || touches.length === 0) return
|
||||
|
||||
const t = touches[0]
|
||||
const dx = t.x - pointer.lastX
|
||||
pointer.lastX = t.x
|
||||
pointer.lastY = t.y
|
||||
|
||||
// 水平滚动
|
||||
viewport.scrollX += dx
|
||||
|
||||
// 限制滚动范围
|
||||
const dataLength = props.data.length
|
||||
const maxScroll = Math.max(0, dataLength * viewport.scaleX - chartWidth.value)
|
||||
viewport.scrollX = clamp(viewport.scrollX, -maxScroll, 0)
|
||||
|
||||
// 更新十字线
|
||||
const index = xToIndex(t.x)
|
||||
updateCrosshair(index)
|
||||
|
||||
scheduleRender()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
pointer.pressed = false
|
||||
}
|
||||
|
||||
function onTap(e: any) {
|
||||
const x = e.detail?.x ?? e.x
|
||||
const index = xToIndex(x)
|
||||
updateCrosshair(index)
|
||||
scheduleRender()
|
||||
}
|
||||
|
||||
function updateCrosshair(index: number) {
|
||||
const dataLength = props.data.length
|
||||
if (dataLength === 0) return
|
||||
|
||||
const clampedIndex = clamp(index, 0, dataLength - 1)
|
||||
const point = props.data[clampedIndex]
|
||||
|
||||
if (point) {
|
||||
crosshair.visible = true
|
||||
crosshair.index = clampedIndex
|
||||
crosshair.data = point
|
||||
|
||||
// 格式化时间标签
|
||||
const seconds = clampedIndex * props.timeConfig.interval
|
||||
crosshair.timeLabel = formatTime(seconds, props.timeConfig.format)
|
||||
|
||||
// 收集指标值
|
||||
crosshair.metrics = props.metrics
|
||||
.filter(m => m.enabled)
|
||||
.map(metric => {
|
||||
const value = point[metric.type]
|
||||
return {
|
||||
type: metric.type,
|
||||
label: metric.label,
|
||||
color: metric.color,
|
||||
value: value != null ? value.toFixed(metric.type === 'speed' ? 1 : 0) : '--'
|
||||
}
|
||||
})
|
||||
|
||||
emit('crosshair-change', {
|
||||
index: clampedIndex,
|
||||
data: point,
|
||||
metrics: crosshair.metrics
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染调度
|
||||
let rafId: number | null = null
|
||||
function scheduleRender() {
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
// 视口管理
|
||||
function followRightEdge() {
|
||||
const dataLength = props.data.length
|
||||
if (dataLength === 0) return
|
||||
|
||||
const targetScroll = -(dataLength * viewport.scaleX - chartWidth.value)
|
||||
viewport.scrollX = Math.max(targetScroll, 0)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
// 获取canvas上下文
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#healthTimeChart').fields({ node: true, size: true }).exec((res: any) => {
|
||||
const node = res && res[0] && res[0].node
|
||||
const size = res && res[0]
|
||||
|
||||
if (!node) {
|
||||
console.error('healthTimeChart canvas node not found')
|
||||
return
|
||||
}
|
||||
|
||||
canvasRef.value = node
|
||||
dpr.value = uni.getSystemInfoSync().pixelRatio || 1
|
||||
|
||||
node.width = Math.floor(size.width * dpr.value)
|
||||
node.height = Math.floor(size.height * dpr.value)
|
||||
|
||||
ctx.value = node.getContext('2d') as CanvasRenderingContext2D
|
||||
if (ctx.value) {
|
||||
ctx.value.scale(dpr.value, dpr.value)
|
||||
}
|
||||
|
||||
scheduleRender()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化
|
||||
watch(() => [props.data, props.metrics, props.timeConfig], () => {
|
||||
if (props.autoScroll) {
|
||||
followRightEdge()
|
||||
}
|
||||
scheduleRender()
|
||||
}, { deep: true })
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
resetView: followRightEdge,
|
||||
setScale: (scale: number) => {
|
||||
viewport.scaleX = clamp(scale, viewport.minScaleX, viewport.maxScaleX)
|
||||
scheduleRender()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-time-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.chart-hud {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.hud-time {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.hud-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.hud-label {
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hud-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
169
components/aklist/aklist.uvue
Normal file
169
components/aklist/aklist.uvue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<supadb
|
||||
:collection="table"
|
||||
:field="field"
|
||||
:filter="filter"
|
||||
:orderby="order"
|
||||
:page-size="pageSize"
|
||||
:page-current="currentPage"
|
||||
:loadtime="preload ? 'auto' : 'manual'"
|
||||
v-slot="{ data, loading, error }"
|
||||
>
|
||||
<list-view :id="id" class="list" :bounces="false" :scroll-y="true" :custom-nested-scroll="true"
|
||||
@scrolltolower="loadMore" associative-container="nested-scroll-view">
|
||||
<list-item class="list-item" v-for="(item, idx) in (data as Array<UTSJSONObject>)" :key="item.getString(primaryKey)" type="10">
|
||||
<slot name="item" :item="item" :index="idx">
|
||||
<!-- 默认插槽内容:仿 long-list-page 展示 -->
|
||||
<view class="list-item-icon">
|
||||
<image class="list-item-icon-image" :src="item.getString('plugin_img_link')"></image>
|
||||
</view>
|
||||
<view class="list-item-fill">
|
||||
<view class="flex-row">
|
||||
<text class="title">{{item.getString('plugin_name')}}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="description-text">{{item.getString('plugin_intro')}}</text>
|
||||
</view>
|
||||
<text class="icon-star">{{convertToStarUnicode(item.getNumber('score'))}}</text>
|
||||
<view class="tag-list">
|
||||
<text class="tag-item" v-for="(tag, index2) in (item.get('tags') as Array<string> ?? [])" :key="index2">{{tag}}</text>
|
||||
</view>
|
||||
<view class="flex-row update-date">
|
||||
<text class="update-date-text">更新日期</text>
|
||||
<text class="update-date-value">{{item.getString('update_date')}}</text>
|
||||
<text class="author">{{item.getString('author_name')}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</slot>
|
||||
</list-item>
|
||||
<list-item class="loading">
|
||||
<slot name="loading">
|
||||
<uni-loading :loading="loading" color="#999" :text="loadingText"></uni-loading>
|
||||
</slot>
|
||||
</list-item>
|
||||
</list-view>
|
||||
<view v-if="error" class="aklist-error">{{ error }}</view>
|
||||
</supadb>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import supadb from '@/components/supadb/supadb.uvue';
|
||||
export default {
|
||||
name: 'AkList',
|
||||
components: { supadb },
|
||||
props: {
|
||||
table: { type: String, required: true },
|
||||
id: { type: String, default: '' },
|
||||
preload: { type: Boolean, default: true },
|
||||
pageSize: { type: Number, default: 10 },
|
||||
primaryKey: { type: String, default: 'id' },
|
||||
filter: { type: Object, default: () => ({}) },
|
||||
order: { type: String, default: '' },
|
||||
field: { type: String, default: '*' }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentPage: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
loadingText(): string {
|
||||
return '加载中...';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadMore() {
|
||||
this.currentPage++;
|
||||
},
|
||||
convertToStarUnicode(score: number): string {
|
||||
const fill_code = '\ue879';
|
||||
const half_code = '\ue87a';
|
||||
const null_code = '\ue87b';
|
||||
const fillStarCount = parseInt((score / 10 % 10) + '');
|
||||
const halfStarCount = score % 10 >= 5 ? 1 : 0;
|
||||
const nullStarCount = 5 - fillStarCount - halfStarCount;
|
||||
let result = '';
|
||||
if (fillStarCount > 0) { result += fill_code.repeat(fillStarCount); }
|
||||
if (halfStarCount > 0) { result += half_code.repeat(halfStarCount); }
|
||||
if (nullStarCount > 0) { result += null_code.repeat(nullStarCount); }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
flex: 1;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.list-item {
|
||||
flex-direction: row;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.list-item-icon {
|
||||
position: relative;
|
||||
}
|
||||
.list-item-icon-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.list-item-fill {
|
||||
flex: 1;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.description-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 19px;
|
||||
}
|
||||
.icon-star {
|
||||
font-family: "UtsStarIcons";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
color: #ffca3e;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
.tag-list {
|
||||
flex-direction: row;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.tag-item {
|
||||
font-size: 12px;
|
||||
background-color: #EFF9F0;
|
||||
color: #639069;
|
||||
border-radius: 20px;
|
||||
margin-right: 5px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.update-date {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.update-date-text {
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
}
|
||||
.update-date-value {
|
||||
font-size: 12px;
|
||||
color: #777777;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.author {
|
||||
font-size: 12px;
|
||||
color: #008000;
|
||||
margin-left: auto;
|
||||
}
|
||||
.loading {
|
||||
padding: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.aklist-error {
|
||||
color: red;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
0
components/login-component.uvue
Normal file
0
components/login-component.uvue
Normal file
196
components/login-component/login-component.uvue
Normal file
196
components/login-component/login-component.uvue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<view class="login-component-wrapper">
|
||||
<scroll-view class="login-container" scroll-y="true" show-scrollbar="false">
|
||||
<!-- 语言切换按钮可选,建议由父组件控制 -->
|
||||
<slot name="language-switch"></slot>
|
||||
|
||||
<view class="content-wrapper">
|
||||
<view class="logo-section">
|
||||
<slot name="logo">
|
||||
<text class="app-title">Trainning Monitor</text>
|
||||
<text class="page-title">{{ $t('user.login.title') }}</text>
|
||||
<text class="page-subtitle">{{ $t('user.login.subtitle') }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
|
||||
<view class="form-container">
|
||||
<form @submit="onSubmit">
|
||||
<view class="input-group" :class="{ 'input-error': emailError }">
|
||||
<text class="input-label">{{ $t('user.login.email') }}</text>
|
||||
<input class="input-field" name="email" type="text" :value="email"
|
||||
:placeholder="$t('user.login.email_placeholder')" @blur="validateEmail" />
|
||||
<text v-if="emailError" class="error-text">{{ emailError }}</text>
|
||||
</view>
|
||||
|
||||
<view class="input-group" :class="{ 'input-error': passwordError }">
|
||||
<text class="input-label">{{ $t('user.login.password') }}</text>
|
||||
<view class="password-input-container">
|
||||
<input class="input-field" name="password" :type="showPassword ? 'text' : 'password'"
|
||||
:value="password" :placeholder="$t('user.login.password_placeholder')"
|
||||
@blur="validatePassword" />
|
||||
<view class="password-toggle" @click="showPassword = !showPassword">
|
||||
<text class="toggle-icon">{{ showPassword ? '👁' : '🙈' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="passwordError" class="error-text">{{ passwordError }}</text>
|
||||
</view>
|
||||
|
||||
<view class="options-row">
|
||||
<view class="remember-me">
|
||||
<checkbox value="rememberMe" color="#2196f3" />
|
||||
<text class="remember-me-text">{{ $t('user.login.remember_me') }}</text>
|
||||
</view>
|
||||
<text class="forgot-password" @click="navigateToForgotPassword">
|
||||
{{ $t('user.login.forgot_password') }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<button form-type="submit" class="login-button" :disabled="isLoading" :loading="isLoading">
|
||||
{{ $t('user.login.login_button') }}
|
||||
</button>
|
||||
<text v-if="generalError" class="general-error">{{ generalError }}</text>
|
||||
</form>
|
||||
|
||||
<slot name="social-login">
|
||||
<view class="social-login">
|
||||
<text class="social-login-text">{{ $t('user.login.or_login_with') }}</text>
|
||||
<view class="social-buttons">
|
||||
<button class="social-button wechat" @click="socialLogin('WeChat')">
|
||||
<text class="social-icon">🟩</text>
|
||||
</button>
|
||||
<button class="social-button qq" @click="socialLogin('QQ')">
|
||||
<text class="social-icon">🔵</text>
|
||||
</button>
|
||||
<button class="social-button sms" @click="socialLogin('SMS')">
|
||||
<text class="social-icon">✉️</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</slot>
|
||||
|
||||
<slot name="register">
|
||||
<view class="register-option">
|
||||
<text class="register-text">{{ $t('user.login.no_account') }}</text>
|
||||
<text class="register-link" @click="navigateToRegister">{{ $t('user.login.register_now') }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import {HOME_REDIRECT} from '@/ak/config.uts'
|
||||
import type { AkReqOptions, AkReqResponse, AkReqError } from '@/uni_modules/ak-req/index.uts';
|
||||
import supa from '@/components/supadb/aksupainstance.uts';
|
||||
import { getCurrentUser, logout } from '@/utils/store.uts';
|
||||
import { switchLocale, getCurrentLocale } from '@/utils/utils.uts';
|
||||
import { tt } from '@/utils/i18nfun.uts'
|
||||
|
||||
|
||||
export default {
|
||||
emits: ['login-success'],
|
||||
props: {
|
||||
showLanguageSwitch: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
password: '',
|
||||
emailError: '',
|
||||
passwordError: '',
|
||||
generalError: '',
|
||||
isLoading: false,
|
||||
showPassword: false,
|
||||
rememberMe: false,
|
||||
currentLocale: getCurrentLocale()
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleLanguage() {
|
||||
const newLocale = this.currentLocale === 'zh-CN' ? 'en-US' : 'zh-CN';
|
||||
switchLocale(newLocale);
|
||||
this.currentLocale = newLocale;
|
||||
uni.showToast({
|
||||
title: tt('user.login.language_switched'),
|
||||
icon: 'success'
|
||||
});
|
||||
},
|
||||
onSubmit(e : UniFormSubmitEvent) {
|
||||
this.handleLogin();
|
||||
},
|
||||
validateEmail() {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (this.email.trim() === "") {
|
||||
this.emailError = tt('user.login.email_required');
|
||||
return false;
|
||||
} else if (!emailRegex.test(this.email)) {
|
||||
this.emailError = tt('user.login.email_invalid');
|
||||
return false;
|
||||
} else {
|
||||
this.emailError = '';
|
||||
return true;
|
||||
}
|
||||
},
|
||||
validatePassword() {
|
||||
if (this.password.trim() === "") {
|
||||
this.passwordError = tt('user.login.password_required');
|
||||
return false;
|
||||
} else if (this.password.length < 6) {
|
||||
this.passwordError = tt('user.login.password_too_short');
|
||||
return false;
|
||||
} else {
|
||||
this.passwordError = '';
|
||||
return true;
|
||||
}
|
||||
},
|
||||
validateForm() {
|
||||
const emailValid = this.validateEmail();
|
||||
const passwordValid = this.validatePassword();
|
||||
return emailValid && passwordValid;
|
||||
},
|
||||
async handleLogin() {
|
||||
this.generalError = '';
|
||||
logout();
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
try {
|
||||
if (this.rememberMe) {
|
||||
uni.setStorageSync('rememberEmail', this.email);
|
||||
} else {
|
||||
uni.removeStorageSync('rememberEmail');
|
||||
}
|
||||
const result = await supa.signIn(
|
||||
this.email,
|
||||
this.password);
|
||||
if (result.user !== null) {
|
||||
const profile = await getCurrentUser();
|
||||
if (profile==null) throw new Error(tt('user.login.profile_update_failed'));
|
||||
uni.showToast({ title: tt('user.login.login_success'), icon: 'success' });
|
||||
this.$emit('login-success', profile);
|
||||
}
|
||||
} catch (err) {
|
||||
this.generalError = tt('user.login.login_failed')
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
navigateToForgotPassword() {
|
||||
this.$emit('forgot-password');
|
||||
},
|
||||
navigateToRegister() {
|
||||
this.$emit('register');
|
||||
},
|
||||
socialLogin(type: string) {
|
||||
this.$emit('social-login', type);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ...可复用原 login.uvue 的样式... */
|
||||
</style>
|
||||
616
components/message/MessageInput.uvue
Normal file
616
components/message/MessageInput.uvue
Normal file
@@ -0,0 +1,616 @@
|
||||
<template>
|
||||
<view class="message-input">
|
||||
<!-- 消息类型选择 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">消息类型</text>
|
||||
<picker-view
|
||||
class="type-picker"
|
||||
:value="selectedTypeIndex"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(type, index) in messageTypes" :key="type.id" class="picker-option">
|
||||
<text>{{ type.name }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
|
||||
<!-- 接收者选择 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">发送给</text>
|
||||
<view class="receiver-selector">
|
||||
<picker-view
|
||||
class="receiver-type-picker"
|
||||
:value="selectedReceiverTypeIndex"
|
||||
@change="handleReceiverTypeChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(type, index) in receiverTypes" :key="type.value" class="picker-option">
|
||||
<text>{{ type.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
|
||||
<view v-if="selectedReceiverType === 'user'" class="user-selector">
|
||||
<input
|
||||
class="user-input"
|
||||
placeholder="输入用户名搜索"
|
||||
v-model="userSearchKeyword"
|
||||
@input="handleUserSearch"
|
||||
/>
|
||||
<scroll-view
|
||||
v-if="filteredUsers.length > 0"
|
||||
class="user-list"
|
||||
scroll-y="true"
|
||||
>
|
||||
<view
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
class="user-item"
|
||||
:class="{ 'selected': isUserSelected(user.id) }"
|
||||
@click="toggleUserSelection(user)"
|
||||
>
|
||||
<text class="user-name">{{ user.name }}</text>
|
||||
<text v-if="isUserSelected(user.id)" class="selected-mark">✓</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-if="selectedReceiverType === 'group'" class="group-selector">
|
||||
<picker-view
|
||||
class="group-picker"
|
||||
:value="selectedGroupIndex"
|
||||
@change="handleGroupChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(group, index) in messageGroups" :key="group.id" class="picker-option">
|
||||
<text>{{ group.name }} ({{ group.member_count }}人)</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息标题 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">标题</text>
|
||||
<input
|
||||
class="title-input"
|
||||
placeholder="请输入消息标题"
|
||||
v-model="messageData.title"
|
||||
:maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">内容 *</text>
|
||||
<textarea
|
||||
class="content-textarea"
|
||||
placeholder="请输入消息内容"
|
||||
v-model="messageData.content"
|
||||
:maxlength="2000"
|
||||
auto-height
|
||||
/>
|
||||
<text class="char-count">{{ getContentLength() }}/2000</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息选项 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">消息选项</text>
|
||||
<view class="options-grid">
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.is_urgent"
|
||||
@change="handleUrgentChange"
|
||||
/>
|
||||
<text class="option-label">紧急消息</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.push_notification"
|
||||
@change="handlePushChange"
|
||||
/>
|
||||
<text class="option-label">推送通知</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.email_notification"
|
||||
@change="handleEmailChange"
|
||||
/>
|
||||
<text class="option-label">邮件通知</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="enableScheduled"
|
||||
@change="handleScheduledChange"
|
||||
/>
|
||||
<text class="option-label">定时发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 定时发送设置 -->
|
||||
<view v-if="enableScheduled" class="input-section">
|
||||
<text class="section-label">发送时间</text>
|
||||
<view class="datetime-picker">
|
||||
<picker-date
|
||||
:value="scheduledDate"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
<picker-time
|
||||
:value="scheduledTime"
|
||||
@change="handleTimeChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优先级设置 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">优先级</text>
|
||||
<picker-view
|
||||
class="priority-picker"
|
||||
:value="selectedPriorityIndex"
|
||||
@change="handlePriorityChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(priority, index) in priorityOptions" :key="priority.value" class="picker-option">
|
||||
<text>{{ priority.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<view class="send-section">
|
||||
<button
|
||||
class="send-btn"
|
||||
:class="{ 'disabled': !canSend() }"
|
||||
:disabled="!canSend() || sending"
|
||||
@click="handleSend"
|
||||
>
|
||||
<text class="send-text">{{ sending ? '发送中...' : '发送消息' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type {
|
||||
MessageType,
|
||||
SendMessageParams,
|
||||
UserOption,
|
||||
GroupOption
|
||||
} from '../../utils/msgTypes.uts'
|
||||
|
||||
type ReceiverTypeOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type PriorityOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MessageInput',
|
||||
props: {
|
||||
messageTypes: {
|
||||
type: Array as PropType<Array<MessageType>>,
|
||||
default: (): Array<MessageType> => []
|
||||
},
|
||||
availableUsers: {
|
||||
type: Array as PropType<Array<UserOption>>,
|
||||
default: (): Array<UserOption> => []
|
||||
},
|
||||
messageGroups: {
|
||||
type: Array as PropType<Array<GroupOption>>,
|
||||
default: (): Array<GroupOption> => []
|
||||
},
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['send'],
|
||||
data() {
|
||||
return {
|
||||
selectedTypeIndex: [0] as Array<number>,
|
||||
selectedReceiverTypeIndex: [0] as Array<number>,
|
||||
selectedGroupIndex: [0] as Array<number>,
|
||||
selectedPriorityIndex: [1] as Array<number>,
|
||||
|
||||
userSearchKeyword: '' as string,
|
||||
selectedUsers: [] as Array<UserOption>,
|
||||
|
||||
enableScheduled: false as boolean,
|
||||
scheduledDate: '' as string,
|
||||
scheduledTime: '' as string,
|
||||
|
||||
messageData: {
|
||||
title: '' as string,
|
||||
content: '' as string,
|
||||
is_urgent: false as boolean,
|
||||
push_notification: true as boolean,
|
||||
email_notification: false as boolean
|
||||
},
|
||||
|
||||
receiverTypes: [
|
||||
{ value: 'user', label: '指定用户' },
|
||||
{ value: 'group', label: '群组' },
|
||||
{ value: 'broadcast', label: '广播' }
|
||||
] as Array<ReceiverTypeOption>,
|
||||
|
||||
priorityOptions: [
|
||||
{ value: 0, label: '普通' },
|
||||
{ value: 50, label: '中等' },
|
||||
{ value: 80, label: '高' },
|
||||
{ value: 100, label: '最高' }
|
||||
] as Array<PriorityOption>
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedReceiverType(): string {
|
||||
const index = this.selectedReceiverTypeIndex[0]
|
||||
return this.receiverTypes[index].value
|
||||
},
|
||||
|
||||
filteredUsers(): Array<UserOption> {
|
||||
if (this.userSearchKeyword.length === 0) {
|
||||
return this.availableUsers
|
||||
}
|
||||
|
||||
const keyword = this.userSearchKeyword.toLowerCase()
|
||||
const filtered: Array<UserOption> = []
|
||||
|
||||
for (let i = 0; i < this.availableUsers.length; i++) {
|
||||
const user = this.availableUsers[i]
|
||||
if (user.name.toLowerCase().indexOf(keyword) !== -1) {
|
||||
filtered.push(user)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleTypeChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedTypeIndex = e.detail.value
|
||||
},
|
||||
|
||||
handleReceiverTypeChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedReceiverTypeIndex = e.detail.value
|
||||
// 清空之前的选择
|
||||
this.selectedUsers = []
|
||||
this.selectedGroupIndex = [0]
|
||||
},
|
||||
|
||||
handleGroupChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedGroupIndex = e.detail.value
|
||||
},
|
||||
|
||||
handlePriorityChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedPriorityIndex = e.detail.value
|
||||
},
|
||||
|
||||
handleUserSearch() {
|
||||
// 搜索逻辑在计算属性中处理
|
||||
},
|
||||
|
||||
isUserSelected(userId: string): boolean {
|
||||
for (let i = 0; i < this.selectedUsers.length; i++) {
|
||||
if (this.selectedUsers[i].id === userId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
toggleUserSelection(user: UserOption) {
|
||||
const index = this.findUserIndex(user.id)
|
||||
if (index !== -1) {
|
||||
this.selectedUsers.splice(index, 1)
|
||||
} else {
|
||||
this.selectedUsers.push(user)
|
||||
}
|
||||
},
|
||||
|
||||
findUserIndex(userId: string): number {
|
||||
for (let i = 0; i < this.selectedUsers.length; i++) {
|
||||
if (this.selectedUsers[i].id === userId) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
},
|
||||
|
||||
handleUrgentChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.is_urgent = e.detail.value
|
||||
},
|
||||
|
||||
handlePushChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.push_notification = e.detail.value
|
||||
},
|
||||
|
||||
handleEmailChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.email_notification = e.detail.value
|
||||
},
|
||||
|
||||
handleScheduledChange(e: UniSwitchChangeEvent) {
|
||||
this.enableScheduled = e.detail.value
|
||||
if (!this.enableScheduled) {
|
||||
this.scheduledDate = ''
|
||||
this.scheduledTime = ''
|
||||
}
|
||||
},
|
||||
|
||||
handleDateChange(e: any) {
|
||||
this.scheduledDate = e.detail.value
|
||||
},
|
||||
|
||||
handleTimeChange(e: any) {
|
||||
this.scheduledTime = e.detail.value
|
||||
},
|
||||
|
||||
getContentLength(): number {
|
||||
return this.messageData.content.length
|
||||
},
|
||||
|
||||
canSend(): boolean {
|
||||
// 必须有内容
|
||||
if (this.messageData.content.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 必须选择接收者
|
||||
if (this.selectedReceiverType === 'user' && this.selectedUsers.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 必须选择消息类型
|
||||
if (this.messageTypes.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
handleSend() {
|
||||
if (!this.canSend() || this.sending) {
|
||||
return
|
||||
}
|
||||
|
||||
const typeIndex = this.selectedTypeIndex[0]
|
||||
const priorityIndex = this.selectedPriorityIndex[0]
|
||||
|
||||
const params: SendMessageParams = {
|
||||
message_type_id: this.messageTypes[typeIndex].id,
|
||||
receiver_type: this.selectedReceiverType,
|
||||
receiver_id: this.getReceiverId(),
|
||||
title: this.messageData.title.length > 0 ? this.messageData.title : null,
|
||||
content: this.messageData.content.trim(),
|
||||
content_type: 'text',
|
||||
attachments: null,
|
||||
priority: this.priorityOptions[priorityIndex].value,
|
||||
expires_at: null,
|
||||
is_urgent: this.messageData.is_urgent,
|
||||
push_notification: this.messageData.push_notification,
|
||||
email_notification: this.messageData.email_notification,
|
||||
sms_notification: false,
|
||||
scheduled_at: this.getScheduledDateTime(),
|
||||
conversation_id: null,
|
||||
parent_message_id: null,
|
||||
metadata: null
|
||||
}
|
||||
|
||||
this.$emit('send', params)
|
||||
},
|
||||
|
||||
getReceiverId(): string | null {
|
||||
if (this.selectedReceiverType === 'user') {
|
||||
if (this.selectedUsers.length === 1) {
|
||||
return this.selectedUsers[0].id
|
||||
}
|
||||
return null // 多用户发送
|
||||
} else if (this.selectedReceiverType === 'group') {
|
||||
const groupIndex = this.selectedGroupIndex[0]
|
||||
return this.messageGroups[groupIndex].id
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
getScheduledDateTime(): string | null {
|
||||
if (!this.enableScheduled || this.scheduledDate.length === 0 || this.scheduledTime.length === 0) {
|
||||
return null
|
||||
}
|
||||
return `${this.scheduledDate} ${this.scheduledTime}:00`
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.messageData.title = ''
|
||||
this.messageData.content = ''
|
||||
this.messageData.is_urgent = false
|
||||
this.messageData.push_notification = true
|
||||
this.messageData.email_notification = false
|
||||
this.selectedUsers = []
|
||||
this.userSearchKeyword = ''
|
||||
this.enableScheduled = false
|
||||
this.scheduledDate = ''
|
||||
this.scheduledTime = ''
|
||||
this.selectedTypeIndex = [0]
|
||||
this.selectedReceiverTypeIndex = [0]
|
||||
this.selectedGroupIndex = [0]
|
||||
this.selectedPriorityIndex = [1]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.type-picker,
|
||||
.receiver-type-picker,
|
||||
.group-picker,
|
||||
.priority-picker {
|
||||
height: 120px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.receiver-selector {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-selector {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
max-height: 150px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-item.selected {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.selected-mark {
|
||||
font-size: 16px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.datetime-picker {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.send-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background-color: #2196f3;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.send-btn.disabled {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.options-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.datetime-picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
353
components/message/MessageItem.uvue
Normal file
353
components/message/MessageItem.uvue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<view class="message-item" :class="{ 'unread': !getIsRead(), 'urgent': item.is_urgent, 'starred': getIsStarred() }" @click="handleClick">
|
||||
<!-- 消息类型指示器 -->
|
||||
<view class="message-indicator" :style="{ backgroundColor: getTypeColor(getMessageType()) }"></view>
|
||||
|
||||
<!-- 消息头部 -->
|
||||
<view class="message-header">
|
||||
<view class="message-meta">
|
||||
<text class="message-type">{{ getTypeName(getMessageType()) }}</text>
|
||||
<text class="message-time">{{ formatTime(item.created_at) }}</text>
|
||||
<text v-if="item.is_urgent" class="urgent-badge">紧急</text>
|
||||
</view>
|
||||
<view class="message-actions">
|
||||
<text v-if="getIsStarred()" class="star-icon">★</text>
|
||||
<text v-if="getIsArchived()" class="archive-icon"></text>
|
||||
<text class="more-icon" @click.stop="toggleActionMenu">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息主体 -->
|
||||
<view class="message-body">
|
||||
<text class="message-title" v-if="item.title !== null">{{ item.title }}</text>
|
||||
<text class="message-content">{{ getDisplayContent(item.content) }}</text>
|
||||
|
||||
<!-- 附件指示 -->
|
||||
<view v-if="hasAttachments(item)" class="attachment-indicator">
|
||||
<text class="attachment-icon"></text>
|
||||
<text class="attachment-count">{{ getAttachmentCount(item) }}个附件</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息底部 -->
|
||||
<view class="message-footer">
|
||||
<text class="sender-info">{{ getSenderInfo(item) }}</text>
|
||||
<view class="message-stats">
|
||||
<text v-if="item.reply_count > 0" class="reply-count">{{ item.reply_count }}回复</text>
|
||||
<text v-if="item.read_count > 0 && item.total_recipients > 0" class="read-status">
|
||||
{{ item.read_count }}/{{ item.total_recipients }}已读
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 操作菜单 -->
|
||||
<view v-if="showActionMenu" class="action-menu" @click.stop="">
|
||||
<view class="action-item" @click="handleAction('read')">
|
||||
<text>{{ getIsRead() ? '标为未读' : '标为已读' }}</text>
|
||||
</view>
|
||||
<view class="action-item" @click="handleAction('star')">
|
||||
<text>{{ getIsStarred() ? '取消收藏' : '收藏' }}</text> </view>
|
||||
<view class="action-item" @click="handleAction('archive')">
|
||||
<text>{{ getIsArchived() ? '取消归档' : '归档' }}</text>
|
||||
</view>
|
||||
<view class="action-item danger" @click="handleAction('delete')">
|
||||
<text>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { formatTime, getTypeName, getTypeColor, truncateText } from '../../utils/msgUtils.uts'
|
||||
import type { MessageWithRecipient } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageItem', props: {
|
||||
item: {
|
||||
type: Object as PropType<MessageWithRecipient>,
|
||||
required: true
|
||||
},
|
||||
showActionButtons: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['click', 'action'],
|
||||
data() {
|
||||
return {
|
||||
showActionMenu: false as boolean
|
||||
}
|
||||
},
|
||||
methods: { handleClick() {
|
||||
this.$emit('click', this.item)
|
||||
},
|
||||
|
||||
toggleActionMenu() {
|
||||
this.showActionMenu = !this.showActionMenu
|
||||
},
|
||||
|
||||
handleAction(action: string) {
|
||||
this.showActionMenu = false
|
||||
this.$emit('action', {
|
||||
action: action,
|
||||
item: this.item
|
||||
})
|
||||
},
|
||||
|
||||
formatTime(dateStr: string): string {
|
||||
return formatTime(dateStr)
|
||||
},
|
||||
|
||||
getTypeName(typeId: string): string {
|
||||
return getTypeName(typeId)
|
||||
},
|
||||
|
||||
getTypeColor(typeId: string): string {
|
||||
return getTypeColor(typeId)
|
||||
},
|
||||
getDisplayContent(content: string | null): string {
|
||||
if (content === null) {
|
||||
return '无内容'
|
||||
}
|
||||
if (content.length > 100) {
|
||||
return content.substring(0, 100) + '...'
|
||||
}
|
||||
return content
|
||||
},
|
||||
hasAttachments(item: MessageWithRecipient): boolean {
|
||||
if (item.attachments === null) {
|
||||
return false
|
||||
}
|
||||
const attachments = item.attachments as UTSJSONObject
|
||||
const files = attachments.getAny('files')
|
||||
return files !== null
|
||||
},
|
||||
|
||||
getAttachmentCount(item: MessageWithRecipient): number {
|
||||
if (item.attachments === null) {
|
||||
return 0
|
||||
}
|
||||
const attachments = item.attachments as UTSJSONObject
|
||||
const files = attachments.getAny('files') as Array<UTSJSONObject> | null
|
||||
return files !== null ? files.length : 0
|
||||
},
|
||||
|
||||
getSenderInfo(item: MessageWithRecipient): string {
|
||||
if (item.sender_name !== null) {
|
||||
return `来自: ${item.sender_name}`
|
||||
}
|
||||
if (item.sender_type === 'system') {
|
||||
return '系统消息'
|
||||
} else if (item.sender_type === 'device') {
|
||||
return '设备消息'
|
||||
} else {
|
||||
return '未知发送者'
|
||||
}
|
||||
},
|
||||
|
||||
// 获取消息类型代码
|
||||
getMessageType(): string {
|
||||
return this.item.message_type ?? this.item.message_type_id ?? 'default'
|
||||
},
|
||||
|
||||
// 获取是否已读
|
||||
getIsRead(): boolean {
|
||||
return this.item.is_read ?? false
|
||||
},
|
||||
|
||||
// 获取是否已收藏
|
||||
getIsStarred(): boolean {
|
||||
return this.item.is_starred ?? false
|
||||
},
|
||||
|
||||
// 获取是否已归档
|
||||
getIsArchived(): boolean {
|
||||
return this.item.is_archived ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-item {
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
border-left: 4px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.message-item.unread {
|
||||
background-color: #f8f9ff;
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
.message-item.urgent {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.message-item.starred {
|
||||
background-color: #fffbf0;
|
||||
}
|
||||
|
||||
.message-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-type {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.urgent-badge {
|
||||
font-size: 10px;
|
||||
color: #ffffff;
|
||||
background-color: #f44336;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
color: #ff9800;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.archive-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.attachment-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 14px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.attachment-count {
|
||||
font-size: 12px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sender-info {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.message-stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reply-count,
|
||||
.read-status {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 16px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.action-item text {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.action-item.danger text {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.action-item:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
436
components/message/MessageList.uvue
Normal file
436
components/message/MessageList.uvue
Normal file
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<view class="message-list">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading && messages.length === 0" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="!loading && messages.length === 0" class="empty-container">
|
||||
<text class="empty-icon"></text>
|
||||
<text class="empty-text">暂无消息</text>
|
||||
<text class="empty-desc">您还没有收到任何消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
v-else
|
||||
class="message-scroll"
|
||||
scroll-y="true"
|
||||
@scrolltolower="handleLoadMore"
|
||||
:refresher-enabled="true"
|
||||
@refresherrefresh="handleRefresh"
|
||||
:refresher-triggered="refreshing"
|
||||
>
|
||||
<!-- 批量选择模式头部 -->
|
||||
<view v-if="selectionMode" class="selection-header">
|
||||
<view class="selection-info">
|
||||
<text class="selection-count">已选择 {{ selectedIds.length }} 条消息</text>
|
||||
</view>
|
||||
<view class="selection-actions">
|
||||
<button class="action-btn" @click="handleBatchRead">
|
||||
<text class="action-text">标为已读</text>
|
||||
</button>
|
||||
<button class="action-btn" @click="handleBatchStar">
|
||||
<text class="action-text">收藏</text>
|
||||
</button>
|
||||
<button class="action-btn danger" @click="handleBatchDelete">
|
||||
<text class="action-text">删除</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息项列表 -->
|
||||
<view class="message-items">
|
||||
<view
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message-item-wrapper"
|
||||
:class="{ 'selected': isSelected(message.id) }"
|
||||
>
|
||||
<!-- 选择框 -->
|
||||
<checkbox
|
||||
v-if="selectionMode"
|
||||
class="selection-checkbox"
|
||||
:checked="isSelected(message.id)"
|
||||
@change="handleToggleSelection(message.id)"
|
||||
/>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<MessageItem
|
||||
:item="message"
|
||||
:show-actions="!selectionMode"
|
||||
@click="handleMessageClick"
|
||||
@action="handleMessageAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="hasMore" class="load-more-container">
|
||||
<view v-if="loadingMore" class="loading-more">
|
||||
<text class="loading-text">加载更多...</text>
|
||||
</view>
|
||||
<button v-else class="load-more-btn" @click="handleLoadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 已到底部 -->
|
||||
<view v-else-if="messages.length > 0" class="end-container">
|
||||
<text class="end-text">已显示全部消息</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<view class="fab-container">
|
||||
<view v-if="!selectionMode" class="fab-group">
|
||||
<button class="fab-btn" @click="handleToggleSelection">
|
||||
<text class="fab-icon">☑️</text>
|
||||
</button>
|
||||
<button class="fab-btn primary" @click="handleCompose">
|
||||
<text class="fab-icon">✏️</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="fab-group">
|
||||
<button class="fab-btn" @click="handleCancelSelection">
|
||||
<text class="fab-icon">❌</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import MessageItem from './MessageItem.uvue'
|
||||
import type { Message } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
components: {
|
||||
MessageItem
|
||||
},
|
||||
props: {
|
||||
messages: {
|
||||
type: Array as PropType<Array<Message>>,
|
||||
default: (): Array<Message> => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
refreshing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectionMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['click', 'action', 'refresh', 'load-more', 'compose', 'batch-action', 'toggle-selection'],
|
||||
data() {
|
||||
return {
|
||||
selectedIds: [] as Array<string>
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectionMode(newVal: boolean) {
|
||||
if (!newVal) {
|
||||
this.selectedIds = []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleMessageClick(message: Message) {
|
||||
if (this.selectionMode) {
|
||||
this.handleToggleSelection(message.id)
|
||||
} else {
|
||||
this.$emit('click', message)
|
||||
}
|
||||
},
|
||||
|
||||
handleMessageAction(event: any) {
|
||||
this.$emit('action', event)
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
},
|
||||
|
||||
handleLoadMore() {
|
||||
if (!this.loadingMore && this.hasMore) {
|
||||
this.$emit('load-more')
|
||||
}
|
||||
},
|
||||
|
||||
handleCompose() {
|
||||
this.$emit('compose')
|
||||
},
|
||||
|
||||
handleToggleSelection() {
|
||||
this.$emit('toggle-selection', !this.selectionMode)
|
||||
},
|
||||
|
||||
handleCancelSelection() {
|
||||
this.selectedIds = []
|
||||
this.$emit('toggle-selection', false)
|
||||
},
|
||||
|
||||
isSelected(messageId: string): boolean {
|
||||
return this.selectedIds.indexOf(messageId) !== -1
|
||||
},
|
||||
|
||||
handleToggleSelection(messageId: string) {
|
||||
const index = this.selectedIds.indexOf(messageId)
|
||||
if (index !== -1) {
|
||||
this.selectedIds.splice(index, 1)
|
||||
} else {
|
||||
this.selectedIds.push(messageId)
|
||||
}
|
||||
},
|
||||
|
||||
handleBatchRead() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('batch-action', {
|
||||
action: 'read',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
},
|
||||
|
||||
handleBatchStar() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('batch-action', {
|
||||
action: 'star',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
},
|
||||
|
||||
handleBatchDelete() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${this.selectedIds.length} 条消息吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.$emit('batch-action', {
|
||||
action: 'delete',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.message-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.selection-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.action-btn.danger .action-text {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.message-items {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-item-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.message-item-wrapper.selected {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.load-more-container,
|
||||
.end-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.end-text {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-btn.primary {
|
||||
background-color: #2196f3;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.fab-btn.primary .fab-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fab-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.selection-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.selection-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
656
components/message/MessageSearch.uvue
Normal file
656
components/message/MessageSearch.uvue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<view class="message-search">
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-header">
|
||||
<view class="search-input-wrapper">
|
||||
<text class="search-icon"></text>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="搜索消息内容、标题或发送者"
|
||||
v-model="keyword"
|
||||
@input="handleInput"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<text
|
||||
v-if="keyword.length > 0"
|
||||
class="clear-icon"
|
||||
@click="handleClear"
|
||||
>
|
||||
✖️
|
||||
</text>
|
||||
</view>
|
||||
<button class="search-btn" @click="handleSearch">
|
||||
<text class="search-text">搜索</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<view class="search-filters">
|
||||
<scroll-view class="filter-scroll" scroll-x="true">
|
||||
<view class="filter-items">
|
||||
<!-- 消息类型筛选 -->
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTypeId === '' }"
|
||||
@click="handleTypeFilter('')"
|
||||
>
|
||||
<text class="filter-text">全部类型</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="type in messageTypes"
|
||||
:key="type.id"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTypeId === type.id }"
|
||||
@click="handleTypeFilter(type.id)"
|
||||
>
|
||||
<text class="filter-text">{{ type.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<view class="filter-divider"></view>
|
||||
<view
|
||||
v-for="timeRange in timeRanges"
|
||||
:key="timeRange.value"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTimeRange === timeRange.value }"
|
||||
@click="handleTimeFilter(timeRange.value)"
|
||||
>
|
||||
<text class="filter-text">{{ timeRange.label }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<view class="filter-divider"></view>
|
||||
<view
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedStatus === status.value }"
|
||||
@click="handleStatusFilter(status.value)"
|
||||
>
|
||||
<text class="filter-text">{{ status.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<view v-if="showHistory && searchHistory.length > 0" class="search-history">
|
||||
<view class="history-header">
|
||||
<text class="history-title">搜索历史</text>
|
||||
<text class="clear-history" @click="clearHistory">清空</text>
|
||||
</view>
|
||||
<view class="history-items">
|
||||
<view
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="history-item"
|
||||
@click="handleHistoryClick(item)"
|
||||
>
|
||||
<text class="history-icon"></text>
|
||||
<text class="history-text">{{ item }}</text>
|
||||
<text class="remove-history" @click.stop="removeHistoryItem(index)">✖️</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索建议 -->
|
||||
<view v-if="showSuggestions && suggestions.length > 0" class="search-suggestions">
|
||||
<view class="suggestions-header">
|
||||
<text class="suggestions-title">搜索建议</text>
|
||||
</view>
|
||||
<view class="suggestions-items">
|
||||
<view
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="suggestion-item"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
>
|
||||
<text class="suggestion-icon"></text>
|
||||
<text class="suggestion-text">{{ suggestion }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<view v-if="showResults" class="search-results">
|
||||
<!-- 结果统计 -->
|
||||
<view class="results-header">
|
||||
<text class="results-count">找到 {{ totalResults }} 条消息</text>
|
||||
<view class="sort-options">
|
||||
<picker-view
|
||||
class="sort-picker"
|
||||
:value="[selectedSortIndex]"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(option, index) in sortOptions" :key="option.value">
|
||||
<text class="sort-text">{{ option.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<MessageList
|
||||
:messages="searchResults"
|
||||
:loading="searching"
|
||||
:loading-more="loadingMore"
|
||||
:has-more="hasMoreResults"
|
||||
@click="handleResultClick"
|
||||
@action="handleResultAction"
|
||||
@load-more="handleLoadMoreResults"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 无结果状态 -->
|
||||
<view v-if="showNoResults" class="no-results">
|
||||
<text class="no-results-icon"></text>
|
||||
<text class="no-results-text">未找到相关消息</text>
|
||||
<text class="no-results-desc">尝试使用其他关键词或调整筛选条件</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import MessageList from './MessageList.uvue'
|
||||
import type { Message, MessageType } from '../../utils/msgTypes.uts'
|
||||
|
||||
type TimeRange = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MessageSearch',
|
||||
components: {
|
||||
MessageList
|
||||
},
|
||||
props: {
|
||||
messageTypes: {
|
||||
type: Array as PropType<Array<MessageType>>,
|
||||
default: (): Array<MessageType> => []
|
||||
},
|
||||
searching: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['search', 'result-click', 'result-action'],
|
||||
data() {
|
||||
return {
|
||||
keyword: '' as string,
|
||||
selectedTypeId: '' as string,
|
||||
selectedTimeRange: '' as string,
|
||||
selectedStatus: '' as string,
|
||||
selectedSortIndex: [0] as Array<number>,
|
||||
|
||||
searchResults: [] as Array<Message>,
|
||||
totalResults: 0 as number,
|
||||
loadingMore: false as boolean,
|
||||
hasMoreResults: false as boolean,
|
||||
|
||||
searchHistory: [] as Array<string>,
|
||||
suggestions: [] as Array<string>,
|
||||
|
||||
showHistory: true as boolean,
|
||||
showSuggestions: false as boolean,
|
||||
showResults: false as boolean,
|
||||
showNoResults: false as boolean,
|
||||
|
||||
timeRanges: [
|
||||
{ value: '', label: '全部时间' },
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'week', label: '本周' },
|
||||
{ value: 'month', label: '本月' }
|
||||
] as Array<TimeRange>,
|
||||
|
||||
statusOptions: [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'unread', label: '未读' },
|
||||
{ value: 'read', label: '已读' },
|
||||
{ value: 'starred', label: '已收藏' },
|
||||
{ value: 'urgent', label: '紧急' }
|
||||
] as Array<StatusOption>,
|
||||
|
||||
sortOptions: [
|
||||
{ value: 'time_desc', label: '时间降序' },
|
||||
{ value: 'time_asc', label: '时间升序' },
|
||||
{ value: 'priority_desc', label: '优先级降序' },
|
||||
{ value: 'relevance', label: '相关度' }
|
||||
] as Array<SortOption>
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadSearchHistory()
|
||||
this.loadSuggestions()
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
// 实时搜索建议
|
||||
if (this.keyword.length > 0) {
|
||||
this.showHistory = false
|
||||
this.showSuggestions = true
|
||||
this.generateSuggestions()
|
||||
} else {
|
||||
this.showSuggestions = false
|
||||
this.showHistory = true
|
||||
this.showResults = false
|
||||
this.showNoResults = false
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch() {
|
||||
if (this.keyword.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.performSearch()
|
||||
this.addToHistory(this.keyword.trim())
|
||||
this.showHistory = false
|
||||
this.showSuggestions = false
|
||||
},
|
||||
|
||||
handleClear() {
|
||||
this.keyword = ''
|
||||
this.showHistory = true
|
||||
this.showSuggestions = false
|
||||
this.showResults = false
|
||||
this.showNoResults = false
|
||||
},
|
||||
|
||||
handleTypeFilter(typeId: string) {
|
||||
this.selectedTypeId = typeId
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleTimeFilter(timeRange: string) {
|
||||
this.selectedTimeRange = timeRange
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleStatusFilter(status: string) {
|
||||
this.selectedStatus = status
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleSortChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedSortIndex = e.detail.value
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleHistoryClick(historyItem: string) {
|
||||
this.keyword = historyItem
|
||||
this.handleSearch()
|
||||
},
|
||||
|
||||
handleSuggestionClick(suggestion: string) {
|
||||
this.keyword = suggestion
|
||||
this.handleSearch()
|
||||
},
|
||||
|
||||
removeHistoryItem(index: number) {
|
||||
this.searchHistory.splice(index, 1)
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
this.searchHistory = []
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
handleResultClick(message: Message) {
|
||||
this.$emit('result-click', message)
|
||||
},
|
||||
|
||||
handleResultAction(event: any) {
|
||||
this.$emit('result-action', event)
|
||||
},
|
||||
|
||||
handleLoadMoreResults() {
|
||||
// 加载更多搜索结果
|
||||
if (!this.loadingMore && this.hasMoreResults) {
|
||||
this.loadingMore = true
|
||||
// TODO: 实现分页搜索
|
||||
this.loadingMore = false
|
||||
}
|
||||
},
|
||||
|
||||
performSearch() {
|
||||
const searchParams = {
|
||||
keyword: this.keyword.trim(),
|
||||
typeId: this.selectedTypeId,
|
||||
timeRange: this.selectedTimeRange,
|
||||
status: this.selectedStatus,
|
||||
sort: this.sortOptions[this.selectedSortIndex[0]].value
|
||||
}
|
||||
|
||||
this.$emit('search', searchParams)
|
||||
this.showResults = true
|
||||
},
|
||||
|
||||
updateSearchResults(results: Array<Message>, total: number, hasMore: boolean) {
|
||||
this.searchResults = results
|
||||
this.totalResults = total
|
||||
this.hasMoreResults = hasMore
|
||||
this.showResults = true
|
||||
this.showNoResults = results.length === 0
|
||||
},
|
||||
|
||||
addToHistory(keyword: string) {
|
||||
const index = this.searchHistory.indexOf(keyword)
|
||||
if (index !== -1) {
|
||||
this.searchHistory.splice(index, 1)
|
||||
}
|
||||
|
||||
this.searchHistory.unshift(keyword)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (this.searchHistory.length > 10) {
|
||||
this.searchHistory = this.searchHistory.slice(0, 10)
|
||||
}
|
||||
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
loadSearchHistory() {
|
||||
try {
|
||||
const historyStr = uni.getStorageSync('message_search_history')
|
||||
if (historyStr !== null && historyStr !== '') {
|
||||
this.searchHistory = JSON.parse(historyStr as string) as Array<string>
|
||||
}
|
||||
} catch (e) {
|
||||
this.searchHistory = []
|
||||
}
|
||||
},
|
||||
|
||||
saveSearchHistory() {
|
||||
try {
|
||||
uni.setStorageSync('message_search_history', JSON.stringify(this.searchHistory))
|
||||
} catch (e) {
|
||||
console.error('保存搜索历史失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
loadSuggestions() {
|
||||
// 加载常用搜索建议
|
||||
this.suggestions = [
|
||||
'系统通知',
|
||||
'训练计划',
|
||||
'作业提醒',
|
||||
'紧急消息',
|
||||
'今日消息'
|
||||
]
|
||||
},
|
||||
|
||||
generateSuggestions() {
|
||||
// 根据关键词生成建议
|
||||
const keyword = this.keyword.toLowerCase()
|
||||
const filtered: Array<string> = []
|
||||
|
||||
for (let i = 0; i < this.suggestions.length; i++) {
|
||||
const suggestion = this.suggestions[i]
|
||||
if (suggestion.toLowerCase().indexOf(keyword) !== -1) {
|
||||
filtered.push(suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
this.suggestions = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-search {
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #2196f3;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.search-text {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.filter-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
background-color: #f0f0f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.filter-item.active .filter-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #e0e0e0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.search-history,
|
||||
.search-suggestions {
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.history-header,
|
||||
.suggestions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.history-title,
|
||||
.suggestions-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.clear-history {
|
||||
font-size: 12px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.history-items,
|
||||
.suggestions-items {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.history-item,
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f8f8f8;
|
||||
}
|
||||
|
||||
.history-icon,
|
||||
.suggestion-icon {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.history-text,
|
||||
.suggestion-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.remove-history {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.sort-picker {
|
||||
width: 120px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.sort-text {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.no-results-text {
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-results-desc {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.search-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.filter-items {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
components/message/MessageStats.uvue
Normal file
252
components/message/MessageStats.uvue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<view class="message-stats">
|
||||
<view class="stats-header">
|
||||
<text class="stats-title">消息统计</text>
|
||||
<text class="stats-time">{{ getCurrentTime() }}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<!-- 总消息数 -->
|
||||
<view class="stat-item">
|
||||
<view class="stat-icon total"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.total_count }}</text>
|
||||
<text class="stat-label">总消息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 未读消息数 -->
|
||||
<view class="stat-item" :class="{ 'highlight': stats.unread_count > 0 }">
|
||||
<view class="stat-icon unread"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.unread_count }}</text>
|
||||
<text class="stat-label">未读</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏消息数 -->
|
||||
<view class="stat-item">
|
||||
<view class="stat-icon starred">⭐</view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.starred_count }}</text>
|
||||
<text class="stat-label">收藏</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 紧急消息数 -->
|
||||
<view class="stat-item" :class="{ 'highlight': stats.urgent_count > 0 }">
|
||||
<view class="stat-icon urgent"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.urgent_count }}</text>
|
||||
<text class="stat-label">紧急</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-detail">
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">今日新增</text>
|
||||
<text class="detail-value">{{ stats.today_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">本周新增</text>
|
||||
<text class="detail-value">{{ stats.week_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">本月新增</text>
|
||||
<text class="detail-value">{{ stats.month_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">已归档</text>
|
||||
<text class="detail-value">{{ stats.archived_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<view class="refresh-btn" @click="handleRefresh">
|
||||
<text class="refresh-text">刷新统计</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { formatTime } from '../../utils/msgUtils.uts'
|
||||
import type { MessageStats } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageStats',
|
||||
props: {
|
||||
stats: {
|
||||
type: Object as PropType<MessageStats>,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['refresh'],
|
||||
methods: {
|
||||
getCurrentTime(): string {
|
||||
const now = new Date()
|
||||
return formatTime(now.toISOString())
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-stats {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.stats-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.stat-item.highlight {
|
||||
background-color: #fff3e0;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-icon.total {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.stat-icon.unread {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.stat-icon.starred {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
|
||||
.stat-icon.urgent {
|
||||
background-color: #fce4ec;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background-color: #2196f3;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:active {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.refresh-text {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
0
components/message/layouts/MessageLayout.vue
Normal file
0
components/message/layouts/MessageLayout.vue
Normal file
0
components/message/layouts/MessageSidebar.vue
Normal file
0
components/message/layouts/MessageSidebar.vue
Normal file
168
components/picker-date/picker-date.uvue
Normal file
168
components/picker-date/picker-date.uvue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<view>
|
||||
<picker-view class="picker-view" :value="pickerValue" @change="onChange" :indicator-style="indicatorStyle"
|
||||
:indicator-class="indicatorClass" :mask-style="maskStyle" :mask-class="maskClass"
|
||||
:mask-top-style="maskTopStyle" :mask-bottom-style="maskBottomStyle">
|
||||
<picker-view-column class="picker-view-column">
|
||||
<view class="item" v-for="(item,index) in years" :key="index"><text class="text">{{item}}年</text></view>
|
||||
</picker-view-column>
|
||||
<picker-view-column class="picker-view-column">
|
||||
<view class="item" v-for="(item,index) in months" :key="index"><text class="text">{{item}}月</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column class="picker-view-column">
|
||||
<view class="item" v-for="(item,index) in days" :key="index"><text class="text">{{item}}日</text></view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
name: 'PickerDate',
|
||||
props: {
|
||||
startYear: { type: Number, default: 1970 },
|
||||
endYear: { type: Number, default: 2030 },
|
||||
value: { type: [Array, String], default: () => new Date().toISOString().split('T')[0] } // 支持数组 [year, month, day] 或字符串 "2025-06-09"
|
||||
},
|
||||
data() {
|
||||
// 不能直接 this.startYear,需本地变量
|
||||
const localStartYear = 1970;
|
||||
const localEndYear = 2030;
|
||||
const years : number[] = [];
|
||||
const months : number[] = [];
|
||||
const days : number[] = [];
|
||||
for (let i = localStartYear; i <= localEndYear; i++) years.push(i);
|
||||
for (let i = 1; i <= 12; i++) months.push(i);
|
||||
for (let i = 1; i <= 31; i++) days.push(i);
|
||||
|
||||
// 获取当前日期作为默认值
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
const currentDay = now.getDate();
|
||||
|
||||
let yearIdx = years.indexOf(currentYear);
|
||||
let monthIdx = months.indexOf(currentMonth);
|
||||
let dayIdx = days.indexOf(currentDay);
|
||||
|
||||
if (yearIdx < 0) yearIdx = 0;
|
||||
if (monthIdx < 0) monthIdx = 0;
|
||||
if (dayIdx < 0) dayIdx = 0;
|
||||
|
||||
return {
|
||||
years,
|
||||
months,
|
||||
days,
|
||||
pickerValue: [yearIdx, monthIdx, dayIdx],
|
||||
localStartYear,
|
||||
localEndYear,
|
||||
indicatorStyle: '',
|
||||
indicatorClass: '',
|
||||
maskStyle: 'display:none;',
|
||||
maskClass: '',
|
||||
maskTopStyle: '',
|
||||
maskBottomStyle: ''
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 生命周期里用 props 初始化本地变量
|
||||
this.localStartYear = this.startYear;
|
||||
this.localEndYear = this.endYear;
|
||||
// 重新生成 years
|
||||
this.years = [];
|
||||
for (let i = this.localStartYear; i <= this.localEndYear; i++) this.years.push(i);
|
||||
|
||||
// 解析传入的日期值
|
||||
const [y, m, d] = this.parseValue(this.value);
|
||||
let yearIdx = this.years.indexOf(y);
|
||||
let monthIdx = this.months.indexOf(m);
|
||||
let dayIdx = this.days.indexOf(d);
|
||||
if (yearIdx < 0) yearIdx = 0;
|
||||
if (monthIdx < 0) monthIdx = 0;
|
||||
if (dayIdx < 0) dayIdx = 0;
|
||||
this.pickerValue = [yearIdx, monthIdx, dayIdx];
|
||||
},
|
||||
mounted() {
|
||||
// 防止 picker-view-column 渲染时未能正确获取当前项,强制刷新 pickerValue
|
||||
this.$nextTick(() => {
|
||||
this.pickerValue = [...this.pickerValue];
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
value(val : any) {
|
||||
const [y, m, d] = this.parseValue(val);
|
||||
let yearIdx = this.years.indexOf(y);
|
||||
let monthIdx = this.months.indexOf(m);
|
||||
let dayIdx = this.days.indexOf(d);
|
||||
if (yearIdx < 0) yearIdx = 0;
|
||||
if (monthIdx < 0) monthIdx = 0;
|
||||
if (dayIdx < 0) dayIdx = 0;
|
||||
this.pickerValue = [yearIdx, monthIdx, dayIdx];
|
||||
}
|
||||
},
|
||||
methods: { // 解析日期值,支持数组和字符串格式
|
||||
parseValue(value : any) : number[] {
|
||||
if (Array.isArray(value)) {
|
||||
const valueArray = value as any[];
|
||||
const year : number = valueArray.length > 0 && typeof valueArray[0] === 'number' ? (valueArray[0] as number) : new Date().getFullYear();
|
||||
const month : number = valueArray.length > 1 && typeof valueArray[1] === 'number' ? (valueArray[1] as number) : (new Date().getMonth() + 1);
|
||||
const day : number = valueArray.length > 2 && typeof valueArray[2] === 'number' ? (valueArray[2] as number) : new Date().getDate();
|
||||
return [year, month, day];
|
||||
} else if (typeof value === 'string' && value.includes('-')) {
|
||||
const parts = value.split('-');
|
||||
const year = parseInt(parts[0]);
|
||||
const month = parseInt(parts[1]);
|
||||
const day = parseInt(parts[2]);
|
||||
const now = new Date();
|
||||
return [
|
||||
isNaN(year) ? now.getFullYear() : year,
|
||||
isNaN(month) ? (now.getMonth() + 1) : month,
|
||||
isNaN(day) ? now.getDate() : day
|
||||
];
|
||||
} else {
|
||||
// 默认返回当前日期
|
||||
const now = new Date();
|
||||
return [now.getFullYear(), now.getMonth() + 1, now.getDate()];
|
||||
}
|
||||
},
|
||||
|
||||
onChange(e : UniPickerViewChangeEvent) {
|
||||
const idxs = e.detail.value;
|
||||
const y = this.years[idxs[0]];
|
||||
const m = this.months[idxs[1]];
|
||||
const maxDay = new Date(y, m, 0).getDate();
|
||||
let d = this.days[idxs[2]];
|
||||
if (d > maxDay) d = maxDay;
|
||||
// 返回字符串格式的日期
|
||||
const monthStr = m < 10 ? '0' + m.toString() : m.toString();
|
||||
const dayStr = d < 10 ? '0' + d.toString() : d.toString();
|
||||
const formattedDate = `${y}-${monthStr}-${dayStr}`;
|
||||
this.$emit('change', formattedDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-view {
|
||||
width: 750rpx;
|
||||
height: 320rpx;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.picker-view-column {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 50px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.text {
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
104
components/picker-time/picker-time.uvue
Normal file
104
components/picker-time/picker-time.uvue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<view>
|
||||
<picker-view
|
||||
class="picker-view"
|
||||
:value="pickerValue"
|
||||
@change="onChange"
|
||||
:indicator-style="indicatorStyle"
|
||||
:indicator-class="indicatorClass"
|
||||
:mask-style="maskStyle"
|
||||
:mask-class="maskClass"
|
||||
:mask-top-style="maskTopStyle"
|
||||
:mask-bottom-style="maskBottomStyle"
|
||||
>
|
||||
<picker-view-column class="picker-view-column">
|
||||
<view class="item" v-for="(item ,index) in hours" :key="index">
|
||||
<text class="text">{{(item as number) < 10 ? '0' + item : item}}时</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column class="picker-view-column">
|
||||
<view class="item" v-for="(item,index) in minutes" :key="index">
|
||||
<text class="text">{{(item as number) < 10 ? '0' + item : item}}分</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column v-if="showSecond" class="picker-view-column">
|
||||
<view class="item" v-for="(item,index) in seconds" :key="index">
|
||||
<text class="text">{{(item as number) < 10 ? '0' + item : item}}秒</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Array, default: () => [12, 0, 0] }, // [hour, minute, second]
|
||||
showSecond: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => i)
|
||||
const seconds = Array.from({ length: 60 }, (_, i) => i)
|
||||
|
||||
const pickerValue = ref<number[]>([])
|
||||
|
||||
const updatePickerValue = (val: number[]) => {
|
||||
const h = val[0] ?? 12
|
||||
const m = val[1] ?? 0
|
||||
const s = val[2] ?? 0
|
||||
let hourIdx = hours.indexOf(h)
|
||||
let minuteIdx = minutes.indexOf(m)
|
||||
let secondIdx = seconds.indexOf(s)
|
||||
if (hourIdx < 0) hourIdx = 0
|
||||
if (minuteIdx < 0) minuteIdx = 0
|
||||
if (secondIdx < 0) secondIdx = 0
|
||||
pickerValue.value = props.showSecond ? [hourIdx, minuteIdx, secondIdx] : [hourIdx, minuteIdx]
|
||||
}
|
||||
|
||||
watch(props.value, (val:number[]) => {
|
||||
updatePickerValue(val)
|
||||
return
|
||||
}, { immediate: true })
|
||||
|
||||
const indicatorStyle = 'height: 50px;'
|
||||
const indicatorClass = ''
|
||||
const maskStyle = ''
|
||||
const maskClass = ''
|
||||
const maskTopStyle = ''
|
||||
const maskBottomStyle = ''
|
||||
|
||||
const onChange = (e: UniPickerViewChangeEvent) => {
|
||||
const idxs = e.detail.value
|
||||
const h = hours[idxs[0]]
|
||||
const m = minutes[idxs[1]]
|
||||
if (props.showSecond) {
|
||||
const s = seconds[idxs[2]]
|
||||
emit('change', [h, m, s])
|
||||
} else {
|
||||
emit('change', [h, m])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-view {
|
||||
width: 750rpx;
|
||||
height: 220px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.picker-view-column {
|
||||
width: 300rpx;
|
||||
}
|
||||
.item {
|
||||
height: 50px;
|
||||
}
|
||||
.text {
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
73
components/simple-icon/simple-icon.uvue
Normal file
73
components/simple-icon/simple-icon.uvue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<text class="simple-icon" :style="iconStyle">{{ iconText }}</text>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
size?: number | null;
|
||||
color?: string | null;
|
||||
}>()
|
||||
|
||||
const iconMap = {
|
||||
// 基本图标
|
||||
'plus': '+',
|
||||
'minus': '-',
|
||||
'close': '×',
|
||||
'closeempty': '×',
|
||||
'checkmarkempty': '✓',
|
||||
'check': '✓',
|
||||
'right': '→',
|
||||
'left': '←',
|
||||
'up': '↑',
|
||||
'down': '↓',
|
||||
|
||||
// 媒体图标
|
||||
'play-filled': '▶',
|
||||
'pause-filled': '⏸',
|
||||
'stop': '⏹',
|
||||
'refresh': '↻',
|
||||
|
||||
// 功能图标
|
||||
'home': '⌂',
|
||||
'person': '👤',
|
||||
'chat': '💬',
|
||||
'list': '☰',
|
||||
'bars': '☰',
|
||||
'calendar': '📅',
|
||||
'clock': '🕐',
|
||||
'info': 'ℹ',
|
||||
'help': '?',
|
||||
'trash': '🗑',
|
||||
'compose': '✏',
|
||||
'videocam': '📹',
|
||||
|
||||
// 体育分析相关图标
|
||||
'file': '📄',
|
||||
'trophy': '🏆',
|
||||
'star': '⭐',
|
||||
'bell': '🔔',
|
||||
|
||||
// 默认
|
||||
'default': '•'
|
||||
}
|
||||
|
||||
const iconText = computed(() => {
|
||||
return (iconMap[props.type] ?? iconMap['default']) as any
|
||||
})
|
||||
|
||||
const iconStyle = computed(() => {
|
||||
return {
|
||||
fontSize: `${props.size}px`,
|
||||
color: props.color,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-icon {
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
28
components/slottest/slottest.uvue
Normal file
28
components/slottest/slottest.uvue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<slot name="default" :data="data" >
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
export default {
|
||||
name: 'slottest',
|
||||
data() {
|
||||
return {
|
||||
data: [{a:'a'},{a:'b'}]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.change-message {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
533
components/supadb/RegionSelector.uvue
Normal file
533
components/supadb/RegionSelector.uvue
Normal 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>
|
||||
155
components/supadb/SIMPLIFIED_API_GUIDE.md
Normal file
155
components/supadb/SIMPLIFIED_API_GUIDE.md
Normal 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的复杂性。
|
||||
194
components/supadb/TYPED_QUERIES_README.md
Normal file
194
components/supadb/TYPED_QUERIES_README.md
Normal 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>` 结果
|
||||
126
components/supadb/TYPE_CONVERSION_FIX_SUMMARY.md
Normal file
126
components/supadb/TYPE_CONVERSION_FIX_SUMMARY.md
Normal 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 平台上提供真正的类型转换,在其他平台上提供编译时类型检查,为开发者提供了更好的开发体验。
|
||||
1027
components/supadb/aksupa - 副本.uts
Normal file
1027
components/supadb/aksupa - 副本.uts
Normal file
File diff suppressed because it is too large
Load Diff
1024
components/supadb/aksupa.uts
Normal file
1024
components/supadb/aksupa.uts
Normal file
File diff suppressed because it is too large
Load Diff
18
components/supadb/aksupainstance.uts
Normal file
18
components/supadb/aksupainstance.uts
Normal 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
|
||||
56
components/supadb/aksupareal.md
Normal file
56
components/supadb/aksupareal.md
Normal 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
|
||||
|
||||
|
||||
|
||||
282
components/supadb/aksuparealtime.uts
Normal file
282
components/supadb/aksuparealtime.uts
Normal file
@@ -0,0 +1,282 @@
|
||||
// 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;
|
||||
|
||||
let changeConfig : any = null;
|
||||
if (params.filter != null && params.filter !== '') {
|
||||
changeConfig = {
|
||||
event: params.event,
|
||||
schema: params.schema,
|
||||
table: params.table,
|
||||
filter: params.filter
|
||||
};
|
||||
} else {
|
||||
changeConfig = {
|
||||
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;
|
||||
36
components/supadb/rag.uts
Normal file
36
components/supadb/rag.uts
Normal 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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
7
components/supadb/raginstance.uts
Normal file
7
components/supadb/raginstance.uts
Normal 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
|
||||
364
components/supadb/supadb.uvue
Normal file
364
components/supadb/supadb.uvue
Normal 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>
|
||||
122
components/supadb/typed-examples.uts
Normal file
122
components/supadb/typed-examples.uts
Normal 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 等方法
|
||||
Reference in New Issue
Block a user