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

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

View File

@@ -0,0 +1,437 @@
import { AkReqUploadOptions, AkReqOptions, AkReqResponse, AkReqError } from './interface.uts';
import { SUPA_URL } from '@/ak/config.uts';
// token 持久化 key
const ACCESS_TOKEN_KEY = 'akreq_access_token';
const REFRESH_TOKEN_KEY = 'akreq_refresh_token';
const EXPIRES_AT_KEY = 'akreq_expires_at';
// 优化:用静态变量缓存 token只有 set/clear 时同步 storage
let _accessToken : string | null = null;
let _refreshToken : string | null = null;
let _expiresAt : number | null = null;
export class AkReq {
static setToken(token : string, refreshToken : string, expiresAt : number) {
_accessToken = token;
_refreshToken = refreshToken;
_expiresAt = expiresAt;
uni.setStorageSync(ACCESS_TOKEN_KEY, token);
uni.setStorageSync(REFRESH_TOKEN_KEY, refreshToken);
uni.setStorageSync(EXPIRES_AT_KEY, expiresAt);
}
static getToken() : string | null {
if (_accessToken != null) return _accessToken;
const t = uni.getStorageSync(ACCESS_TOKEN_KEY) as string | null;
_accessToken = t;
return t;
}
static getRefreshToken() : string | null {
if (_refreshToken != null) return _refreshToken;
const t = uni.getStorageSync(REFRESH_TOKEN_KEY) as string | null;
_refreshToken = t;
return t;
} static getExpiresAt() : number | null {
const val = _expiresAt;
if (val != null) return val;
const t = uni.getStorageSync(EXPIRES_AT_KEY) as number | null;
_expiresAt = t;
return t;
}
static clearToken() {
_accessToken = null;
_refreshToken = null;
_expiresAt = null;
uni.removeStorageSync(ACCESS_TOKEN_KEY);
uni.removeStorageSync(REFRESH_TOKEN_KEY);
uni.removeStorageSync(EXPIRES_AT_KEY);
} // 判断 token 是否即将过期提前5分钟刷新
static isTokenExpiring() : boolean {
const expiresAt = this.getExpiresAt();
if (expiresAt === null || expiresAt == 0) {
return true;
}
const now = Math.floor(Date.now() / 1000);
return (expiresAt - now) < 300; // 提前5分钟刷新
}
// 自动刷新 token返回 true=已刷新false=未刷新
static async refreshTokenIfNeeded(apikey ?: string) : Promise<boolean> {
// 没有 access_token 直接返回,不刷新
const accessToken = this.getToken();
if (accessToken === null || accessToken === "") {
return false;
}
if (!this.isTokenExpiring()) {
return false;
}
const refreshToken = this.getRefreshToken();
if (refreshToken === null || refreshToken === "") {
this.clearToken();
return false;
}
// 构造 header必须带 apikey
let headers = {} as UTSJSONObject;
if (apikey !== null && apikey !== "") {
headers = Object.assign({}, headers, { 'apikey': apikey }) as UTSJSONObject;
} try {
const res = await this.request({
url: SUPA_URL + '/auth/v1/token?grant_type=refresh_token',
method: 'POST',
data: ({ refresh_token: refreshToken } as UTSJSONObject),
headers: headers,
contentType: 'application/json'
}, true); // skipRefresh=true避免递归
const data = res.data as UTSJSONObject | null;
let accessToken : string | null = null;
let refreshTokenNew : string | null = null;
let expiresAt : number | null = null;
if (data != null && typeof data.getString === 'function' && typeof data.getNumber === 'function') {
accessToken = data.getString('access_token');
refreshTokenNew = data.getString('refresh_token');
expiresAt = data.getNumber('expires_at');
}
if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) {
this.setToken(accessToken, refreshTokenNew, expiresAt);
return true;
} else {
this.clearToken();
return false;
}
} catch (e) {
this.clearToken();
return false;
}
}
// options: AkReqOptions, skipRefresh: boolean = false
static async request(options : AkReqOptions, skipRefresh ?: boolean) : Promise<AkReqResponse<any>> {
// 自动刷新 token
if (skipRefresh != true) {
let apikey : string | null = null;
const headersObj = options.headers;
if (headersObj != null && typeof headersObj.getString === 'function') {
apikey = headersObj.getString('apikey');
}
await this.refreshTokenIfNeeded(apikey);
}
// 统一 header自动带上 Authorization/Content-Type/Accept
let headers = options.headers ?? ({} as UTSJSONObject);
const token = this.getToken();
if (token != null && token != "") {
headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject;
}
let contentType = options.contentType ?? '';
if (headers != null && typeof headers.getString === 'function') {
const headerContentType = headers.getString('Content-Type');
if (headerContentType != null) {
contentType = headerContentType;
}
}
if (contentType != null && contentType != "") {
headers = Object.assign({}, headers, { 'Content-Type': contentType }) as UTSJSONObject;
}
// 默认 Accept
headers = Object.assign({ Accept: 'application/json' } as UTSJSONObject, headers) as UTSJSONObject;
const timeout = options.timeout ?? 10000;
const maxRetry = Math.max(0, options.retryCount ?? 0);
const baseDelay = Math.max(0, options.retryDelayMs ?? 300);
const doOnce = (): Promise<AkReqResponse<any>> => {
return new Promise<AkReqResponse<any>>((resolve) => {
uni.request({
url: options.url,
method: options.method ?? 'GET',
data: options.data,
header: headers,
timeout: timeout,
success: (res) => {
// HEAD 请求特殊处理:没有响应体,只有 headers
if (options.method == 'HEAD') {
const result = AkReq.createResponse<any>(
res.statusCode,
[] as Array<any>,
res.header as UTSJSONObject
);
resolve(result);
return;
}
// 兼容 res.data 可能为 string 或 UTSJSONObject 或 UTSArray
let data : UTSJSONObject | Array<UTSJSONObject> | null;
if (typeof res.data == 'string') {
const strData = res.data as string;
if (strData.length > 0 && /[^\s]/.test(strData)) {
try {
data = JSON.parse(strData) as UTSJSONObject;
} catch (e) {
// 非 JSON 响应(例如纯文本/空响应/数字等),保持原始字符串,避免 JSON.parse 崩溃
data = new UTSJSONObject({ raw: strData });
}
} else {
data = null;
}
} else if (Array.isArray(res.data)) {
data = res.data as UTSJSONObject[];
} else {
const objData = res.data as UTSJSONObject | null;
data = objData;
if (objData != null) {
const accessToken = objData.getString('access_token');
const refreshTokenNew = objData.getString('refresh_token');
const expiresAt = objData.getNumber('expires_at');
if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) {
AkReq.setToken(accessToken, refreshTokenNew, expiresAt);
}
}
}
const result = AkReq.createResponse<any>(
res.statusCode,
data ?? {},
res.header as UTSJSONObject
);
resolve(result);
},
fail: (err) => {
const result = AkReq.createResponse<any>(
err.errCode,
err.data ?? {},
{} as UTSJSONObject,
new UniError('uni-request', err.errCode, err.errMsg ?? 'request fail')
);
resolve(result);
}
});
});
};
let attempt = 0;
let lastRes: AkReqResponse<any> | null = null;
while (attempt <= maxRetry) {
const res = await doOnce();
lastRes = res;
// 仅网络失败/超时errCode 非 0 且 status 非 2xx/3xx时重试
const status = res.status ?? 0;
const isOk = status >= 200 && status < 400;
if (isOk) return res;
if (attempt === maxRetry) break;
// 简单退避
const delay = baseDelay * Math.pow(2, attempt);
await new Promise<void>((r) => { setTimeout(() => { r(); }, delay); });
attempt++;
}
const finalRes = lastRes!!;
// 全局处理 401 未授权:在非 refresh 场景下,清理 token。
// 测试模式下不强制跳登录页,避免影响任意跳转调试。
if ((finalRes.status === 401) && (skipRefresh !== true)) {
try {
this.clearToken();
uni.showToast({ title: '未授权或登录已过期,请重新登录', icon: 'none' });
} catch (e) {}
try {
// 动态读取配置,避免 ak-req 模块与业务工程强耦合
const cfg = require('@/ak/config.uts') as any
const isTest = cfg != null ? (cfg.IS_TEST_MODE === true) : false
// if (!isTest) {
// uni.reLaunch({ url: '/pages/user/login' });
// }
} catch (e) {
// try { uni.reLaunch({ url: '/pages/user/login' }); } catch (e2) {}
}
}
return finalRes;
}
// 新增 upload 方法,支持 uni.uploadFile自动带 token/apikey
static async upload(options : AkReqUploadOptions) : Promise<AkReqResponse<any>> {
// 上传前尝试刷新 token若即将过期。优先从 options.headers 或 apikey 字段获取 apikey
let apikey: string | null = null;
const hdr = options.headers;
if (hdr != null && typeof hdr.getString === 'function') {
apikey = hdr.getString('apikey');
}
if (apikey == null && options.apikey != null) apikey = options.apikey;
await this.refreshTokenIfNeeded(apikey != null ? apikey : null);
let headers = options.headers ?? ({} as UTSJSONObject);
const token = this.getToken();
if (token != null && token !== "") {
headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject;
}
if (apikey != null && apikey !== "") {
headers = Object.assign({}, headers, { apikey: apikey }) as UTSJSONObject;
}
// 默认 Accept
headers = Object.assign({ Accept: 'application/json' } as UTSJSONObject, headers) as UTSJSONObject;
const timeout = options.timeout ?? 10000;
const maxRetry = Math.max(0, options.retryCount ?? 0);
const baseDelay = Math.max(0, options.retryDelayMs ?? 300);
const doOnce = (): Promise<AkReqResponse<any>> => {
return new Promise<AkReqResponse<any>>((resolve) => {
const task = uni.uploadFile({
url: options.url,
filePath: options.filePath,
name: options.name,
formData: options.formData ?? {},
header: headers,
timeout: timeout,
success: (res : UploadFileSuccess) => {
let parsed: UTSJSONObject | null = null;
try {
parsed = JSON.parse(res.data) as UTSJSONObject;
} catch (e) {
parsed = null;
}
if (parsed != null) {
const accessToken = parsed.getString('access_token');
const refreshTokenNew = parsed.getString('refresh_token');
const expiresAt = parsed.getNumber('expires_at');
if (accessToken !== null && refreshTokenNew !== null && expiresAt !== null) {
AkReq.setToken(accessToken, refreshTokenNew, expiresAt);
}
}
const result = AkReq.createResponse<any>(
res.statusCode,
parsed ?? {},
headers
);
resolve(result);
},
fail: (err) => {
const result = AkReq.createResponse<any>(
err.errCode,
err.data ?? {},
{} as UTSJSONObject,
new UniError('uni-upload', err.errCode, err.errMsg ?? 'upload fail')
);
resolve(result);
}
});
if (options.onProgress != null && task != null) {
const progressCallback = (res: OnProgressUpdateResult) => {
const percent = res.progress as number; // 0-100
const sent = res.totalBytesSent as number | null;
const expected = res.totalBytesExpectedToSend as number | null;
if (options.onProgress != null) {
options.onProgress(percent, sent, expected);
}
};
task.onProgressUpdate(progressCallback);
}
});
};
let attempt = 0;
let lastRes: AkReqResponse<any> | null = null;
while (attempt <= maxRetry) {
const res = await doOnce();
lastRes = res;
const status = res.status ?? 0;
const isOk = status >= 200 && status < 400;
if (isOk) return res;
if (attempt === maxRetry) break;
const delay = baseDelay * Math.pow(2, attempt);
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay);
});
attempt++;
}
return lastRes!!;
}
// 辅助方法:创建 AkReqResponse 对象,避免类型推断问题
static createResponse<T>(
status: number,
data: T | Array<T> ,
headers: UTSJSONObject,
error: UniError | null = null,
total: number | null = null,
page: number | null = null,
limit: number | null = null,
hasmore: boolean | null = null,
origin: any | null = null
): AkReqResponse<T> {
return {
status,
data,
headers,
error,
total,
page,
limit,
hasmore,
origin
};
}
// 新增:支持类型转换的请求方法
static async requestAs<T = any>(options : AkReqOptions, skipRefresh ?: boolean) : Promise<AkReqResponse<T|Array<T>>> {
const response = await this.request(options, skipRefresh);
// 如果原始 data 是 null直接返回 null
// if (response.data == null) {
// return {
// status: response.status,
// data: null,
// headers: response.headers,
// error: response.error,
// total: response.total,
// page: response.page,
// limit: response.limit,
// hasmore: response.hasmore,
// origin: response.origin
// } as AkReqResponse<T|Array<T>>;
// }
// 尝试类型转换
let convertedData: T | null = null;
try {
// #ifdef APP-ANDROID
if (response.data instanceof UTSJSONObject) {
convertedData = response.data.parse<T>();
} else if (Array.isArray(response.data)) {
const convertedArray: Array<any> = [];
const dataArray = response.data;
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i];
if (item instanceof UTSJSONObject) {
const parsed = item.parse<T>();
if (parsed != null) {
convertedArray.push(parsed);
}
} else {
convertedArray.push(item);
}
}
convertedData = convertedArray as T;
}
// #endif
// #ifndef APP-ANDROID
convertedData = response.data as T;
// #endif
} catch (e) {
console.warn('类型转换失败,使用原始 UTSJSONObject:', e);
// 转换失败时,返回原始 UTSJSONObject
convertedData = response.data as T;
}
const aaa = {
status: response.status,
data: convertedData!!,
headers: response.headers,
error: response.error,
total: response.total,
page: response.page,
limit: response.limit,
hasmore: response.hasmore,
origin: response.origin
} ;
return aaa
}
}
export default AkReq;

View File

@@ -0,0 +1,2 @@
export * from './interface.uts';
export * from './ak-req.uts';

View File

@@ -0,0 +1,48 @@
// ak-req 类型定义
export type AkReqOptions = {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' |'HEAD';
data?: UTSJSONObject | Array<UTSJSONObject>;
headers?: UTSJSONObject;
timeout?: number;
contentType?: string; // 新增,支持顶级 contentType
// 可选:重试设置(仅网络错误/超时触发)。默认重试 0 次
retryCount?: number; // 最大重试次数,默认 0
retryDelayMs?: number; // 首次重试延迟,默认 300ms指数退避
};
// 上传参数类型定义
export type AkReqUploadOptions = {
url: string,
filePath: string,
name: string,
formData?: UTSJSONObject,
headers?: UTSJSONObject,
apikey?: string,
timeout?: number,
// 进度回调0-100注意H5/APP 平台支持不同)
onProgress?: (progress: number, transferredBytes?: number, totalBytes?: number) => void,
// 可选:重试设置(仅网络错误/超时触发)。默认 0
retryCount?: number,
retryDelayMs?: number
};
export type AkReqResponse<T = any> = {
status: number;
data: T | Array<T> | null; // 支持 null
headers: UTSJSONObject;
error: UniError | null;
total:number |null;
page: number |null;
limit: number |null;
hasmore:boolean |null;
origin: any | null;
};
export class AkReqError extends Error {
code: number;
constructor(message: string, code: number = 0) {
super(message);
this.code = code;
this.name = 'AkReqError';
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "ak-req",
"version": "0.0.1",
"main": "ak-req.uts",
"types": "interface.uts",
"uni_modules": {
"uni_modules": true
}
}

View File

@@ -0,0 +1,425 @@
<template>
<view class="ec-wrap">
<!-- 通过 props option 喂给 renderjs -->
<view
class="ec-canvas"
:prop="option"
:change:prop="ec.setOption"
:data-theme="theme"
/>
</view>
</template>
<script>
export default {
name: "EChartsView",
props: {
option: { type: Object, default: () => ({}) },
theme: { type: String, default: "light" },
},
};
</script>
<script module="ec" lang="renderjs">
import * as echarts from "echarts";
// 使用 Map 存储多个图表实例(支持多个 EChartsView 组件)
const charts = new Map();
const resizeObservers = new Map();
// 地图数据加载状态
let chinaMapLoaded = false;
let chinaMapLoading = false;
// 加载并注册中国地图
async function loadChinaMap() {
if (chinaMapLoaded) {
return Promise.resolve();
}
if (chinaMapLoading) {
// 如果正在加载,等待加载完成
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (chinaMapLoaded) {
clearInterval(checkInterval);
resolve();
}
}, 100);
// 最多等待 10 秒
setTimeout(() => {
clearInterval(checkInterval);
resolve();
}, 10000);
});
}
chinaMapLoading = true;
try {
// 从在线 CDN 加载中国地图 GeoJSON 数据
// 使用 ECharts 官方示例数据源
const response = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json');
if (!response.ok) {
// 如果第一个源失败,尝试备用源
const backupResponse = await fetch('https://echarts.apache.org/examples/data/map/china.json');
if (!backupResponse.ok) {
throw new Error('Failed to load China map data');
}
const geoJson = await backupResponse.json();
echarts.registerMap('china', geoJson);
} else {
const geoJson = await response.json();
echarts.registerMap('china', geoJson);
}
chinaMapLoaded = true;
console.log('[EChartsView] 中国地图数据已加载并注册');
} catch (error) {
console.error('[EChartsView] 加载中国地图数据失败:', error);
// 即使加载失败,也标记为已尝试,避免重复请求
chinaMapLoaded = false;
} finally {
chinaMapLoading = false;
}
}
function getChartKey(el) {
// 使用元素的唯一标识作为 key
if (!el._echartsKey) {
el._echartsKey = 'echarts_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return el._echartsKey;
}
function ensureChart(el, retryCount = 0) {
if (!el) return null;
const key = getChartKey(el);
let chart = charts.get(key);
// 如果图表已存在且有效,直接返回
if (chart && !chart.isDisposed()) {
return chart;
}
// 如果图表已销毁,从 Map 中移除
if (chart && chart.isDisposed()) {
charts.delete(key);
const ro = resizeObservers.get(key);
if (ro) {
ro.disconnect();
resizeObservers.delete(key);
}
chart = null;
}
// 确保元素有尺寸
const rect = el.getBoundingClientRect();
const computedStyle = window.getComputedStyle(el);
const width = parseFloat(computedStyle.width) || rect.width;
const height = parseFloat(computedStyle.height) || rect.height;
// 如果尺寸为 0尝试延迟初始化最多重试 10 次)
if ((width === 0 || height === 0) && retryCount < 10) {
if (retryCount === 0) {
console.warn('[EChartsView] 容器尺寸为 0延迟初始化', { width, height, rect });
}
// 使用指数退避策略,避免无限循环
const delay = Math.min(100 * Math.pow(1.5, retryCount), 1000);
setTimeout(() => {
ensureChart(el, retryCount + 1);
}, delay);
return null;
}
// 如果重试次数过多,使用默认尺寸
if (width === 0 || height === 0) {
console.warn('[EChartsView] 容器尺寸仍为 0使用默认尺寸', { width, height });
// 使用父元素尺寸或默认值
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : { width: 800, height: 400 };
const finalWidth = width || parentRect.width || 800;
const finalHeight = height || parentRect.height || 400;
if (finalWidth > 0 && finalHeight > 0) {
// 设置元素尺寸
el.style.width = finalWidth + 'px';
el.style.height = finalHeight + 'px';
} else {
console.error('[EChartsView] 无法确定容器尺寸,跳过初始化');
return null;
}
}
try {
// 注意:地图数据加载在 setOption 中处理,这里不处理
// 因为 ensureChart 是同步函数,不能使用 await
chart = echarts.init(el, null, {
renderer: "canvas",
width: rect.width,
height: rect.height
});
charts.set(key, chart);
// 自适应:监听容器尺寸变化
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const c = charts.get(key);
if (c && !c.isDisposed() && width > 0 && height > 0) {
try {
c.resize({ width, height });
} catch (e) {
console.warn('[EChartsView] resize 失败', e);
}
}
}
});
ro.observe(el);
resizeObservers.set(key, ro);
} else {
// 兜底
const resizeHandler = () => {
const c = charts.get(key);
if (c && !c.isDisposed()) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
c.resize();
} catch (e) {
console.warn('[EChartsView] resize 失败', e);
}
}
}
};
window.addEventListener("resize", resizeHandler);
// 存储 handler 以便后续清理
el._resizeHandler = resizeHandler;
}
return chart;
} catch (e) {
console.error('[EChartsView] 初始化失败', e);
return null;
}
}
function disposeChart(el) {
if (!el) return;
const key = getChartKey(el);
const chart = charts.get(key);
if (chart && !chart.isDisposed()) {
try {
chart.dispose();
} catch (e) {
console.warn('[EChartsView] dispose 失败', e);
}
}
charts.delete(key);
const ro = resizeObservers.get(key);
if (ro) {
ro.disconnect();
resizeObservers.delete(key);
}
if (el._resizeHandler) {
window.removeEventListener("resize", el._resizeHandler);
delete el._resizeHandler;
}
}
export default {
mounted() {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
if (!el) {
console.error('[EChartsView] 找不到容器元素');
return;
}
// 延迟初始化,确保 DOM 已渲染
setTimeout(() => {
ensureChart(el);
}, 50);
},
beforeDestroy() {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
if (el) {
disposeChart(el);
}
},
methods: {
async setOption(option, oldOption) {
const el = this.$el.querySelector(".ec-canvas") || this.$el;
if (!el) {
console.error('[EChartsView] setOption: 找不到容器元素');
return;
}
// 检查 option 是否有效
if (!option || typeof option !== 'object') {
console.warn('[EChartsView] setOption: option 无效', option);
return;
}
// 检查是否使用了地图,如果是,先加载地图数据
const needsMap = option.geo || (option.series && Array.isArray(option.series) && option.series.some(s => s.type === 'map' && s.map === 'china'));
if (needsMap) {
await loadChinaMap();
}
// 保存 option 供 ensureChart 使用
el._pendingOption = option;
// 确保图表已初始化
let c = ensureChart(el);
if (!c) {
// 如果容器尺寸为 0使用 ResizeObserver 等待容器尺寸可用
if (typeof ResizeObserver !== "undefined") {
let roTimeout = null;
const ro = new ResizeObserver(async (entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
ro.disconnect();
if (roTimeout) clearTimeout(roTimeout);
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
// 如果使用地图,确保地图已加载
if (needsMap) {
await loadChinaMap();
}
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
requestAnimationFrame(() => {
const c2 = charts.get(getChartKey(el));
if (c2 && !c2.isDisposed()) {
try {
c2.resize({ width, height });
} catch (e) {
// 忽略已销毁的错误
}
}
});
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
}
break;
}
}
});
ro.observe(el);
// 设置超时避免无限等待5秒后强制使用默认尺寸
roTimeout = setTimeout(() => {
ro.disconnect();
console.warn('[EChartsView] ResizeObserver 超时,尝试使用默认尺寸初始化');
// 尝试使用父元素尺寸或默认值
const parentRect = el.parentElement ? el.parentElement.getBoundingClientRect() : null;
const defaultWidth = parentRect ? parentRect.width : 800;
const defaultHeight = parentRect ? parentRect.height : 400;
if (defaultWidth > 0 && defaultHeight > 0) {
el.style.width = defaultWidth + 'px';
el.style.height = defaultHeight + 'px';
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
}
}
}, 5000);
} else {
// 兜底延迟重试最多3次
let retryCount = 0;
const maxRetries = 3;
const retry = () => {
if (retryCount >= maxRetries) {
console.warn('[EChartsView] 重试次数过多,跳过初始化');
return;
}
retryCount++;
setTimeout(async () => {
if (needsMap) {
await loadChinaMap();
}
c = ensureChart(el);
if (c && !c.isDisposed()) {
try {
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
} catch (e) {
console.error('[EChartsView] setOption 失败', e);
}
} else if (retryCount < maxRetries) {
retry();
}
}, 200 * retryCount);
};
retry();
}
return;
}
// 检查图表是否已销毁
if (c.isDisposed()) {
console.warn('[EChartsView] setOption: 图表已销毁,重新初始化');
charts.delete(getChartKey(el));
if (needsMap) {
await loadChinaMap();
}
c = ensureChart(el);
if (!c) return;
}
try {
// 如果使用地图,确保地图已加载
if (needsMap) {
await loadChinaMap();
}
// 深拷贝 option 确保是纯 JS 对象
const plainOption = JSON.parse(JSON.stringify(option));
c.setOption(plainOption, true);
// 使用 requestAnimationFrame 避免 resize 警告
requestAnimationFrame(() => {
const key = getChartKey(el);
const c2 = charts.get(key);
if (c2 && !c2.isDisposed()) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
c2.resize({ width: rect.width, height: rect.height });
} catch (e) {
// 忽略已销毁的错误(可能组件已卸载)
}
}
}
});
} catch (e) {
console.error('[EChartsView] setOption 失败', e, option);
}
},
},
};
</script>
<style>
.ec-wrap {
width: 100%;
height: 100%;
}
.ec-canvas {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,67 @@
# i18n 国际化模块
这是一个简化的 i18n 国际化模块,用于支持多语言切换。
## 功能特性
- 支持多语言切换
- 兼容 Vue I18n 的 API
- 支持在 Vue 组件中使用 `$t` 方法
- 支持通过 `tt` 工具函数进行翻译
## 使用方法
### 在 Vue 组件中使用
```vue
<template>
<view>
<text>{{ $t('user.login.title') }}</text>
</view>
</template>
```
### 在脚本中使用
```typescript
import i18n from '@/uni_modules/i18n/index.uts'
import { tt } from '@/utils/i18nfun.uts'
// 方式1直接使用 i18n
const text = i18n.global.t('user.login.title')
// 方式2使用工具函数
const text2 = tt('user.login.title')
```
### 切换语言
```typescript
import { switchLocale, getCurrentLocale } from '@/utils/utils.uts'
// 切换语言
switchLocale('en-US')
// 获取当前语言
const current = getCurrentLocale()
```
## 配置
默认语言为 `zh-CN`,可以在 `index.uts` 中修改 `defaultLocale` 常量。
## 注意事项
当前实现是简化版本,`t` 函数直接返回 key。实际项目中需要
1. 加载语言资源文件
2. 实现真正的翻译逻辑
3. 支持参数插值
## 文件结构
```
uni_modules/i18n/
├── index.uts # 主模块文件
├── package.json # 模块配置
└── README.md # 说明文档
```

View File

@@ -0,0 +1,39 @@
// i18n 国际化配置
// 这是一个简化的 i18n 实现,用于支持多语言切换
// 语言资源
const messages: UTSJSONObject = new UTSJSONObject()
// 默认语言
const defaultLocale = 'zh-CN'
// 当前语言(响应式)
let currentLocale = defaultLocale
// 翻译函数
function t(key: string, values: UTSJSONObject | null = null, locale: string | null = null): string {
const targetLocale = locale ?? currentLocale
// 这里应该从 messages 中获取翻译,简化实现直接返回 key
// 实际项目中应该加载语言资源文件
return key
}
// 创建响应式 locale 对象
const localeObj = {
get value(): string {
return currentLocale
},
set value(newLocale: string) {
currentLocale = newLocale
}
}
// 导出 i18n 对象(兼容 Vue I18n 的 API
const i18nInstance = {
global: {
t: t,
locale: localeObj
}
}
export default i18nInstance

View File

@@ -0,0 +1,9 @@
{
"name": "i18n",
"version": "1.0.0",
"main": "index.uts",
"types": "index.uts",
"uni_modules": {
"uni_modules": true
}
}

View File

@@ -0,0 +1,6 @@
## 0.0.32024-05-29
- feat: `SetClipboardDataOption``showToast`为默认弹出
## 0.0.22024-05-29
- feat: `SetClipboardDataOption`增加`showToast`对齐web
## 0.0.12024-04-12
- init

View File

@@ -0,0 +1,32 @@
<template>
<view>
<button @click="setClipboard">设置</button>
<button @click="getClipboard">获取</button>
</view>
</template>
<script setup>
import {setClipboardData, getClipboardData, SetClipboardDataOption, GetClipboardDataOption, GetClipboardDataSuccessCallbackOption} from '@/uni_modules/lime-clipboard'
const setClipboard = ()=>{
setClipboardData({
data: '這里是內容',
showToast: true,
success(res){
console.log('res', res.errMsg)
}
} as SetClipboardDataOption)
}
const getClipboard = () =>{
getClipboardData({
success(res: GetClipboardDataSuccessCallbackOption){
console.log('res', res)
}
} as GetClipboardDataOption)
}
</script>
<style>
</style>

View File

@@ -0,0 +1,86 @@
{
"id": "lime-clipboard",
"displayName": "lime-clipboard 剪贴板",
"version": "0.0.3",
"description": "lime-clipboard 系参考小程序setClipboardData和getClipboardData实现的UTS API支持uniappX(web,ios,安卓)",
"keywords": [
"lime-clipboard",
"setClipboardData",
"getClipboardData",
"clipboard",
"剪贴板"
],
"repository": "",
"engines": {
"HBuilderX": "^4.11"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "y"
},
"App": {
"app-android": "y",
"app-ios": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
# lime-clipboard
- 参考小程序`setClipboardData``getClipboardData`实现的UTS API支持uniappX(web,ios,安卓)
## 安装
插件市场导入即可
## 使用
使用方法跟小程序的一样
```ts
import {setClipboardData, getClipboardData, SetClipboardDataOption, GetClipboardDataOption, GetClipboardDataSuccessCallbackOption} from '@/uni_modules/lime-clipboard'
setClipboardData({
data: '这里是內容',
success(res){
console.log('res', res.errMsg)
}
} as SetClipboardDataOption)
getClipboardData({
success(res: GetClipboardDataSuccessCallbackOption){
console.log('res', res)
}
} as GetClipboardDataOption)
```
## API
因为直接参照小程序`setClipboardData``getClipboardData`API所以可以直接按[clipboard](https://uniapp.dcloud.net.cn/api/system/clipboard.html)文档来

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="cn.limeui.clipboard">
<uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND" />
<uses-permission android:name="android.permission.WRITE_CLIPBOARD_IN_BACKGROUND" />
</manifest>

View File

@@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@@ -0,0 +1,80 @@
import ClipData from "android.content.ClipData";
import ClipboardManager from "android.content.ClipboardManager";
import Context from "android.content.Context";
import { UTSAndroid } from "io.dcloud.uts";
import { SetClipboardDataOption, GetClipboardDataOption, GetClipboardDataSuccessCallbackOption } from '../interface.uts';
import { GeneralCallbackResultImpl } from '../unierror.uts';
export function setClipboardData(options : SetClipboardDataOption) {
const handleClipboardOperationFailure = () => {
const res = new GeneralCallbackResultImpl(9010002)
options.fail?.(res)
options.complete?.(res)
}
try {
const context = UTSAndroid.getAppContext();
if (context != null) {
const clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
const clip = ClipData.newPlainText('label', options.data);
clipboard.setPrimaryClip(clip);
const res = new GeneralCallbackResultImpl(9010001)
if(options.showToast != false){
uni.showToast({
icon: 'success',
title: '内容已复制'
})
}
options.success?.(res)
options.complete?.(res)
} else {
handleClipboardOperationFailure()
}
} catch (e) {
handleClipboardOperationFailure()
}
}
export function getClipboardData(options : GetClipboardDataOption) {
const handleClipboardOperationFailure = () => {
const res = new GeneralCallbackResultImpl(9010002, 'get')
options.fail?.(res)
options.complete?.(res)
}
try {
const context = UTSAndroid.getAppContext();
if (context != null) {
const clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
const clip = clipboard.getPrimaryClip();
if (clip != null && clip.getItemCount() > 0) {
const text = clip.getItemAt(0).getText();
if (text != null) {
options.success?.({
data: text.toString(),
errMsg: '成功'
} as GetClipboardDataSuccessCallbackOption)
} else {
// 如果剪贴板没有文本数据,调用失败的处理函数
handleClipboardOperationFailure();
}
} else {
// 如果剪贴板没有内容,调用失败的处理函数
handleClipboardOperationFailure();
}
} else {
// 如果无法获取应用上下文,调用失败的处理函数
handleClipboardOperationFailure();
}
} catch (e) {
handleClipboardOperationFailure()
}
}

View File

@@ -0,0 +1,3 @@
{
"deploymentTarget": "9"
}

View File

@@ -0,0 +1,33 @@
import { UIPasteboard } from "UIKit"
import { SetClipboardDataOption, GetClipboardDataOption, GetClipboardDataSuccessCallbackOption } from '../interface.uts';
import { GeneralCallbackResultImpl } from '../unierror.uts';
export function setClipboardData(options : SetClipboardDataOption){
let pasteboard = UIPasteboard.general
pasteboard.string = options.data
const res = new GeneralCallbackResultImpl(9010001)
if(options.showToast != false){
uni.showToast({
icon: 'success',
title: '内容已复制'
})
}
options.success?.(res)
options.complete?.(res)
}
export function getClipboardData(options : GetClipboardDataOption){
let pasteboard = UIPasteboard.general;
const res = new GeneralCallbackResultImpl(9010002, 'get')
if(pasteboard.string == null){
options.fail?.(res)
options.complete?.(res)
} else {
options.success?.({
errMsg: 'getClipboardData:ok',
data: `${pasteboard.string!}`
} as GetClipboardDataSuccessCallbackOption)
options.complete?.(res)
}
}

View File

@@ -0,0 +1,19 @@
export * from './interface'
import {SetClipboardDataOption, GetClipboardDataOption} from './interface'
/**
* 设置系统剪贴板的内容
*
* 文档: [http://uniapp.dcloud.io/api/system/clipboard?id=setclipboarddata](http://uniapp.dcloud.io/api/system/clipboard?id=setclipboarddata)
*/
export function setClipboardData(options : SetClipboardDataOption) {
uni.setClipboardData(options as UniNamespace.SetClipboardDataOptions)
}
/**
* 获得系统剪贴板的内容
*
* 文档: [http://uniapp.dcloud.io/api/system/clipboard?id=getclipboarddata](http://uniapp.dcloud.io/api/system/clipboard?id=getclipboarddata)
*/
export function getClipboardData(options : GetClipboardDataOption) {
uni.getClipboardData(options as UniNamespace.GetClipboardDataOptions)
}

View File

@@ -0,0 +1,66 @@
/**
* 错误码
* 根据uni错误码规范要求建议错误码以90开头以下是错误码示例
* - 9010001 错误信息1
* - 9010002 错误信息2
*/
export type LimeClipboardErrorCode = 9010001 | 9010002;
/**
* myApi 的错误回调参数
*/
export interface GeneralCallbackResult extends IUniError {
errCode : LimeClipboardErrorCode
};
// export interface GeneralCallbackResult {
// /** 错误信息 */
// errMsg : string
// }
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
export type SetClipboardDataCompleteCallback = (res : UniError) => void
/** 接口调用失败的回调函数 */
export type SetClipboardDataFailCallback = (res : UniError) => void
/** 接口调用成功的回调函数 */
export type SetClipboardDataSuccessCallback = (res : UniError) => void
export type SetClipboardDataOption = {
showToast?: boolean
/** 剪贴板的内容 */
data : string
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
complete ?: SetClipboardDataCompleteCallback
/** 接口调用失败的回调函数 */
fail ?: SetClipboardDataFailCallback
/** 接口调用成功的回调函数 */
success ?: SetClipboardDataSuccessCallback
}
export type GetClipboardDataSuccessCallbackOption = {
/** 剪贴板的内容 */
data : string
errMsg : string
}
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
export type GetClipboardDataCompleteCallback = (res : UniError) => void
/** 接口调用失败的回调函数 */
export type GetClipboardDataFailCallback = (res : UniError) => void
/** 接口调用成功的回调函数 */
export type GetClipboardDataSuccessCallback = (
option : GetClipboardDataSuccessCallbackOption
) => void
export type GetClipboardDataOption = {
/** 接口调用结束的回调函数(调用成功、失败都会执行) */
complete ?: GetClipboardDataCompleteCallback
/** 接口调用失败的回调函数 */
fail ?: GetClipboardDataFailCallback
/** 接口调用成功的回调函数 */
success ?: GetClipboardDataSuccessCallback
}

View File

@@ -0,0 +1,39 @@
/* 此规范为 uni 规范,可以按照自己的需要选择是否实现 */
import { LimeClipboardErrorCode, GeneralCallbackResult } from "./interface.uts"
/**
* 错误主题
* 注意:错误主题一般为插件名称,每个组件不同,需要使用时请更改。
* [可选实现]
*/
export const UniErrorSubject = 'ClipboardData';
/**
* 错误信息
* @UniError
* [可选实现]
*/
export const UniErrors : Map<LimeClipboardErrorCode, string> = new Map([
/**
* 错误码及对应的错误信息
*/
[9010001, 'ClipboardData:ok'],
[9010002, 'ClipboardData:failed'],
]);
/**
* 错误对象实现
*/
export class GeneralCallbackResultImpl extends UniError implements GeneralCallbackResult {
/**
* 错误对象构造函数
*/
constructor(errCode : LimeClipboardErrorCode, type: string = 'set') {
super();
this.errSubject = type + UniErrorSubject;
this.errCode = errCode;
this.errMsg = type + (UniErrors[errCode] ?? "");
}
}

View File

@@ -0,0 +1,55 @@
// agent session 列表参数类型
export type AgentSessionListOptions = {
page?: number,
page_size?: number,
orderby?: string,
desc?: boolean,
id?: string,
user_id?: string,
dsl?: string
}
// rag-req 类型声明
export type RagReqOptions = {
url: string;
method?: string;
headers?: UTSJSONObject;
data?: any;
timeout?: number;
};
export type RagReqResponse<T = any> = {
status: number;
data: T;
headers: UTSJSONObject;
error?: string | null;
total?: number | null;
page?: number | null;
limit?: number | null;
hasmore?: boolean | null;
origin?: any | null;
};
export interface RagReqError {
code: number;
message: string;
}
export interface RagSessionData {
id: string;
session_name?: string;
total_messages?: number;
last_message_at?: string;
is_active?: boolean;
// 索引签名已移除,兼容 UTS
}
export interface RagMessageData {
id?: string;
role?: string;
content?: string;
created_at?: string;
answer?: string;
message?: string;
// 索引签名已移除,兼容 UTS
}

View File

@@ -0,0 +1,218 @@
import { RagReqOptions, RagReqResponse, RagReqError, RagSessionData, RagMessageData, AgentSessionListOptions } from './interface.uts';
// token/session 持久化 key
const RAG_ACCESS_TOKEN_KEY = 'ragreq_access_token';
const RAG_SESSION_ID_KEY = 'ragreq_session_id';
let _accessToken : string | null = null;
let _sessionId : string | null = null;
export type RagReqConfig {
baseUrl : string;
apiKey ?: string;
}
export class RagReq {
private baseUrl : string;
constructor(config : RagReqConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
if ((config.apiKey ?? '') !== '') {
RagReq.setToken(config.apiKey!);
}
}
// 设置 token
static setToken(token : string) {
_accessToken = token;
uni.setStorageSync(RAG_ACCESS_TOKEN_KEY, token);
}
static getToken() : string | null {
if (_accessToken != null) return _accessToken;
const t = uni.getStorageSync(RAG_ACCESS_TOKEN_KEY) as string | null;
_accessToken = t;
return t;
}
static clearToken() {
_accessToken = null;
uni.removeStorageSync(RAG_ACCESS_TOKEN_KEY);
}
// sessionId 管理
static setSessionId(sessionId : string) {
_sessionId = sessionId;
uni.setStorageSync(RAG_SESSION_ID_KEY, sessionId);
}
static getSessionId() : string | null {
if (_sessionId != null) return _sessionId;
const t = uni.getStorageSync(RAG_SESSION_ID_KEY) as string | null;
_sessionId = t;
return t;
}
static clearSessionId() {
_sessionId = null;
uni.removeStorageSync(RAG_SESSION_ID_KEY);
}
// 通用 request
async request(options : RagReqOptions) : Promise<RagReqResponse<any>> {
let headers = options.headers ?? ({} as UTSJSONObject);
const token = RagReq.getToken();
if ((token ?? '') !== '') {
headers = Object.assign({}, headers, { Authorization: `Bearer ${token}` }) as UTSJSONObject;
}
const url = options.url.startsWith('http') ? options.url : this.baseUrl + options.url;
return new Promise<RagReqResponse<any>>((resolve, reject) => {
uni.request({
url,
method: options.method ?? 'POST',
data: options.data,
header: headers,
timeout: options.timeout ?? 10000,
success: (res) => {
let data : UTSJSONObject | Array<UTSJSONObject> | null;
if (typeof res.data == 'string') {
try { data = JSON.parse(res.data as string) as UTSJSONObject; } catch { data = null; }
} else if (Array.isArray(res.data)) {
data = res.data as UTSJSONObject[];
} else {
data = res.data as UTSJSONObject | null;
}
resolve({
status: res.statusCode,
data: data ?? {},
headers: res.header as UTSJSONObject,
error: null
} as RagReqResponse<any>);
},
fail: (err) => {
resolve({
status: err.errCode,
data: null as any,
headers: {} as UTSJSONObject,
error: err.errMsg ?? 'request fail'
} as RagReqResponse<any>);
}
});
});
}
// 发送消息到 RAG
async sendMessage(message : string, sessionId ?: string) : Promise<RagReqResponse<RagMessageData>> {
const sid = sessionId ?? RagReq.getSessionId();
const reqOpt : RagReqOptions = {
url: '/api/session/chat',
method: 'POST',
data: { message, session_id: sid } as UTSJSONObject
};
const res = await this.request(reqOpt);
return res;
}
// 获取指定 agent 的会话列表(新接口)
async getAgentSessionList(
agentId: string,
options?: AgentSessionListOptions
): Promise<RagReqResponse<any>> {
let url = `/api/v1/agents/${agentId}/sessions`;
const params: string[] = [];
if (options!=null) {
if (options.page !== null) params.push(`page=${options.page}`);
if (options?.page_size !== null) params.push(`page_size=${options?.page_size}`);
if (options?.orderby!=null) params.push(`orderby=${encodeURIComponent(options?.orderby??'')}`);
if (options.desc !== null) params.push(`desc=${options.desc!=null ? 'true' : 'false'}`);
if (options.id!='') params.push(`id=${encodeURIComponent(options?.id??'')}`);
if (options.user_id!='') params.push(`user_id=${encodeURIComponent(options?.user_id??'')}`);
if (options.dsl!=null) params.push(`dsl=${encodeURIComponent(options?.dsl??'')}`);
}
if (params.length > 0) {
url += '?' + params.join('&');
}
const headers = {} as UTSJSONObject;
// Authorization header will be auto-added in request()
return await this.request({
url,
method: 'GET',
headers
});
}
// 获取历史消息
async getHistory(sessionId ?: string) : Promise<RagReqResponse<Array<RagMessageData>>> {
const sid = sessionId ?? RagReq.getSessionId();
const reqOpt : RagReqOptions = {
url: `/api/session/history?session_id=${sid}`,
method: 'GET'
};
const res = await this.request(reqOpt);
return res;
}
// 新建会话
async createSession() : Promise<RagReqResponse<RagSessionData>> {
const reqOpt : RagReqOptions = {
url: '/api/session/create',
method: 'POST',
data: {} as UTSJSONObject
};
const res = await this.request(reqOpt);
return res;
}
// 创建会话(支持 json 或 form-data
async createAgentSession(
agentId : string,
params ?: UTSJSONObject,
userId ?: string,
isFormData : boolean = false
) : Promise<RagReqResponse<any>> {
let url = `/api/v1/agents/${agentId}/sessions`;
if ((userId ?? '') !== '') url += `?user_id=${encodeURIComponent(userId!)}`;
let headers = {} as UTSJSONObject;
let data : any = params ?? {};
if (isFormData) {
headers['Content-Type'] = 'multipart/form-data';
} else {
headers['Content-Type'] = 'application/json';
}
return await this.request({
url,
method: 'POST',
headers,
data
});
}
// 与 agent 对话
async converseWithAgent(
agentId : string,
body : UTSJSONObject
) : Promise<RagReqResponse<UTSJSONObject>> {
const url = `/api/v1/agents/${agentId}/completions`;
const headers = { 'Content-Type': 'application/json' } as UTSJSONObject;
return await this.request({
url,
method: 'POST',
headers,
data: body
});
}
// 删除 agent 的会话(批量)
async deleteAgentSessions(
agentId: string,
ids: string[]
): Promise<RagReqResponse<any>> {
const url = `/api/v1/agents/${agentId}/sessions`;
const headers = { 'Content-Type': 'application/json' } as UTSJSONObject;
const data = { ids } as UTSJSONObject;
return await this.request({
url,
method: 'DELETE',
headers,
data
});
}
}
export default RagReq;