feat(platforms): refactor platform architecture and update API endpoints
- Refactor app.tsx to use new platform factory authorization system - Update TemplateCard to use new URL property names (inputExampleUrl, outputExampleUrl) - Enhance platform factory with authorization support for H5, TT, and WeApp - Add new platform-specific authorization modules for multi-platform support - Update SDK server endpoints to match new API structure - Fix useAd hook factory instantiation timing - Update API base URL for development environment
This commit is contained in:
parent
9d890478d8
commit
142a38d7d1
35
src/app.tsx
35
src/app.tsx
|
|
@ -1,36 +1,29 @@
|
||||||
import { PropsWithChildren } from 'react'
|
import { PropsWithChildren } from 'react'
|
||||||
import Taro, { useLaunch } from '@tarojs/taro'
|
import { useLaunch } from '@tarojs/taro'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
import configStore from './store'
|
import configStore from './store'
|
||||||
|
|
||||||
import './app.css'
|
import './app.css'
|
||||||
import { useServerSdk } from './hooks'
|
import { createPlatformFactory } from './platforms'
|
||||||
|
|
||||||
const store = configStore()
|
const store = configStore()
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren<any>) {
|
function App({ children }: PropsWithChildren<any>) {
|
||||||
const serverSdk = useServerSdk()
|
useLaunch(async () => {
|
||||||
useLaunch(() => {
|
const authorize = createPlatformFactory().createAuthorize()
|
||||||
Taro.login({
|
|
||||||
success: async (res) => {
|
try {
|
||||||
try {
|
// 检查登录状态,包括OAuth 2.0回调处理
|
||||||
const login = await serverSdk.login({ ...res })
|
const isLoggedIn = await authorize.checkLogin()
|
||||||
// 获取accessToken然后存储到本地
|
if (!isLoggedIn) {
|
||||||
if (login?.tokens?.accessToken) {
|
// 可以根据需要决定是否自动跳转登录
|
||||||
serverSdk.setAccessToken(login.tokens.accessToken, login.user.id)
|
await authorize.login()
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
console.error('Taro.login failed:', error)
|
|
||||||
}
|
}
|
||||||
})
|
} catch (error) {
|
||||||
console.log('App launched.')
|
console.error('登录检查失败:', error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// children 是将要会渲染的页面
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||||
|
|
||||||
// 检测output是否为视频
|
// 检测output是否为视频
|
||||||
const isOutputVideo = useMemo(() => {
|
const isOutputVideo = useMemo(() => {
|
||||||
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.outputExample);
|
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.outputExampleUrl);
|
||||||
}, [template.outputExample]);
|
}, [template.outputExampleUrl]);
|
||||||
|
|
||||||
// 检测input是否为视频
|
// 检测input是否为视频
|
||||||
const isInputVideo = useMemo(() => {
|
const isInputVideo = useMemo(() => {
|
||||||
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.inputExample);
|
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.inputExampleUrl);
|
||||||
}, [template.inputExample]);
|
}, [template.inputExampleUrl]);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
|
|
@ -86,10 +86,10 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||||
{isOutputVideo ? (
|
{isOutputVideo ? (
|
||||||
// 当output是视频时,只显示单个视频
|
// 当output是视频时,只显示单个视频
|
||||||
<View className="single-video-container">
|
<View className="single-video-container">
|
||||||
<Image className="video-poster" src={template.inputExample} mode="aspectFill" />
|
<Image className="video-poster" src={template.inputExampleUrl} mode="aspectFill" />
|
||||||
<Video
|
<Video
|
||||||
className="single-video"
|
className="single-video"
|
||||||
src={template.outputExample}
|
src={template.outputExampleUrl}
|
||||||
autoplay
|
autoplay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
|
|
@ -116,7 +116,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||||
{isInputVideo ? (
|
{isInputVideo ? (
|
||||||
<Video
|
<Video
|
||||||
className="full-video"
|
className="full-video"
|
||||||
src={template.inputExample}
|
src={template.inputExampleUrl}
|
||||||
autoplay
|
autoplay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
|
|
@ -127,7 +127,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||||
controls={false}
|
controls={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image className="full-image" src={template.inputExample} mode="aspectFill" lazyLoad />
|
<Image className="full-image" src={template.inputExampleUrl} mode="aspectFill" lazyLoad />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -138,7 +138,7 @@ export default function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||||
clipPath: `polygon(${splitPosition}% 0%, 100% 0%, 100% 100%, ${splitPosition}% 100%)`,
|
clipPath: `polygon(${splitPosition}% 0%, 100% 0%, 100% 100%, ${splitPosition}% 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image className="full-image" src={template.outputExample} mode="aspectFill" lazyLoad />
|
<Image className="full-image" src={template.outputExampleUrl} mode="aspectFill" lazyLoad />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 可拖拽的分割线 */}
|
{/* 可拖拽的分割线 */}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ interface UseAdOptions {
|
||||||
onClose?: (isEnded: boolean) => void; // 广告关闭回调,传入是否完整观看
|
onClose?: (isEnded: boolean) => void; // 广告关闭回调,传入是否完整观看
|
||||||
}
|
}
|
||||||
// 创建平台广告实例
|
// 创建平台广告实例
|
||||||
const factory = createPlatformFactory()
|
|
||||||
export function useAd(options?: UseAdOptions): UseAdReturn {
|
export function useAd(options?: UseAdOptions): UseAdReturn {
|
||||||
|
const factory = createPlatformFactory()
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [adAvailable, setAdAvailable] = useState(false);
|
const [adAvailable, setAdAvailable] = useState(false);
|
||||||
const [adLoaded, setAdLoaded] = useState(false); // 广告是否已加载完成
|
const [adLoaded, setAdLoaded] = useState(false); // 广告是否已加载完成
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { MediaTT, RewardedVideoAdTT, UserInfoTT } from "./tt";
|
import { MediaTT, RewardedVideoAdTT, UserInfoTT } from "./tt";
|
||||||
import { Media, RewardedVideoAd, UserInfo } from "./core";
|
import { Media, RewardedVideoAd, UserInfo } from "./core";
|
||||||
import { MediaWeApp, RewardedVideoAdWeApp, UserInfoWeApp } from "./weapp";
|
import { MediaWeApp, RewardedVideoAdWeApp, UserInfoWeApp } from "./weapp";
|
||||||
|
import { Authorize } from "./types/index";
|
||||||
|
import { H5Authorize } from "./h5/index";
|
||||||
|
import { TtAuthorize } from "./tt/index";
|
||||||
|
import { WeappAuthorize } from "./weapp/index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序平台全局对象类型声明
|
* 小程序平台全局对象类型声明
|
||||||
|
|
@ -97,6 +101,17 @@ export class PlatformFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createAuthorize(): Authorize {
|
||||||
|
switch (this.platform) {
|
||||||
|
case 'bytedance':
|
||||||
|
return new TtAuthorize()
|
||||||
|
case 'wechat':
|
||||||
|
return new WeappAuthorize()
|
||||||
|
default:
|
||||||
|
return new H5Authorize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前平台类型
|
* 获取当前平台类型
|
||||||
* @returns Platform 当前平台
|
* @returns Platform 当前平台
|
||||||
|
|
@ -111,7 +126,7 @@ export class PlatformFactory {
|
||||||
* @returns boolean 是否支持
|
* @returns boolean 是否支持
|
||||||
*/
|
*/
|
||||||
static isSupportedPlatform(platform: string): platform is Platform {
|
static isSupportedPlatform(platform: string): platform is Platform {
|
||||||
return ['tt', 'weapp', 'alipay', 'swan', 'qq'].includes(platform);
|
return ['tt', 'weapp', 'alipay', 'swan', 'qq', 'h5'].includes(platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Authorize } from "../types/index";
|
||||||
|
import { useServerSdk } from "../../hooks/index";
|
||||||
|
|
||||||
|
export class H5Authorize extends Authorize {
|
||||||
|
async checkLogin(): Promise<boolean> {
|
||||||
|
// 检查URL中是否有OAuth回调参数
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const accessToken = urlParams.get('access_token');
|
||||||
|
const userId = urlParams.get('user_id');
|
||||||
|
if (accessToken && userId) {
|
||||||
|
// 处理OAuth回调
|
||||||
|
try {
|
||||||
|
const serverSdk = useServerSdk();
|
||||||
|
serverSdk.setAccessToken(accessToken, userId);
|
||||||
|
|
||||||
|
// 清理URL参数,避免重复处理
|
||||||
|
const newUrl = window.location.pathname;
|
||||||
|
window.history.replaceState(null, '', newUrl);
|
||||||
|
|
||||||
|
console.log('OAuth登录成功,token已保存');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理OAuth回调失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有回调参数,检查本地存储的token
|
||||||
|
try {
|
||||||
|
const serverSdk = useServerSdk();
|
||||||
|
const profile = await serverSdk.profile();
|
||||||
|
return !!profile;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('本地token无效或已过期');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(): Promise<void> {
|
||||||
|
const { hostname, protocol, port } = window.location;
|
||||||
|
let baseUrl = `${protocol}//${hostname}`
|
||||||
|
if (hostname === 'localhost') {
|
||||||
|
baseUrl = `${protocol}//${hostname}:${port}`
|
||||||
|
}
|
||||||
|
window.location.href = `https://mixvideo-workflow.bowong.cc/auth/google/authorize?redirect_url=${baseUrl}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './authorize';
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Taro from "@tarojs/taro";
|
||||||
|
import { Authorize } from "../types/index";
|
||||||
|
import { useServerSdk } from "../../hooks/index";
|
||||||
|
|
||||||
|
export class TtAuthorize extends Authorize {
|
||||||
|
async checkLogin(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const serverSdk = useServerSdk();
|
||||||
|
const profile = await serverSdk.profile();
|
||||||
|
return !!profile;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('字节跳动小程序token无效或已过期');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async login(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const serverSdk = useServerSdk()
|
||||||
|
Taro.login({
|
||||||
|
success: async (res) => {
|
||||||
|
try {
|
||||||
|
const login = await serverSdk.login({ ...res })
|
||||||
|
// 获取accessToken然后存储到本地
|
||||||
|
if (login?.tokens?.accessToken) {
|
||||||
|
serverSdk.setAccessToken(login.tokens.accessToken, login.user.id)
|
||||||
|
resolve()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`登录失败, access token为空`))
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './authorize'
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export abstract class Authorize {
|
||||||
|
abstract login(): Promise<void>;
|
||||||
|
abstract checkLogin(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './authorize'
|
||||||
|
|
@ -291,7 +291,6 @@ export class UserInfoWeApp extends UserInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class MediaWeApp extends Media {
|
export class MediaWeApp extends Media {
|
||||||
downloadFile(url: string): Promise<IDownloadFile> {
|
downloadFile(url: string): Promise<IDownloadFile> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Taro from "@tarojs/taro";
|
||||||
|
import { Authorize } from "../types/index";
|
||||||
|
import { useServerSdk } from "../../hooks/index";
|
||||||
|
|
||||||
|
export class WeappAuthorize extends Authorize {
|
||||||
|
async checkLogin(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const serverSdk = useServerSdk();
|
||||||
|
const profile = await serverSdk.profile();
|
||||||
|
return !!profile;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('微信小程序token无效或已过期');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async login(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const serverSdk = useServerSdk()
|
||||||
|
Taro.login({
|
||||||
|
success: async (res) => {
|
||||||
|
try {
|
||||||
|
const login = await serverSdk.login({ ...res })
|
||||||
|
// 获取accessToken然后存储到本地
|
||||||
|
if (login?.tokens?.accessToken) {
|
||||||
|
serverSdk.setAccessToken(login.tokens.accessToken, login.user.id)
|
||||||
|
resolve()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`登录失败, access token为空`))
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './authorize'
|
||||||
|
|
@ -15,8 +15,8 @@ export interface Template {
|
||||||
description: string; // 模板详细描述
|
description: string; // 模板详细描述
|
||||||
creditCost: number; // 积分消耗
|
creditCost: number; // 积分消耗
|
||||||
version: string; // 版本号
|
version: string; // 版本号
|
||||||
inputExample: string; // 原始图片
|
inputExampleUrl: string; // 原始图片
|
||||||
outputExample: string; // 输出图片
|
outputExampleUrl: string; // 输出图片
|
||||||
tags: string[]; // 标签数组
|
tags: string[]; // 标签数组
|
||||||
templateType: string; // 模板类型
|
templateType: string; // 模板类型
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ export class SdkServer {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
|
|
||||||
constructor(url: string = `https://sd2s2bl25ni4n75na2bog.apigateway-cn-beijing.volceapi.com`, timeout: number = 5 * 60 * 1000) {
|
constructor(url: string = `http://127.0.0.1:8787`, timeout: number = 5 * 60 * 1000) {
|
||||||
this.baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
this.baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
this.timeout = timeout;
|
this.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
@ -167,8 +167,8 @@ export class SdkServer {
|
||||||
* GET /
|
* GET /
|
||||||
*/
|
*/
|
||||||
async getAllTemplates(): Promise<Template[]> {
|
async getAllTemplates(): Promise<Template[]> {
|
||||||
const response = await this.request<Template[]>('/api/v1/templates');
|
const response = await this.request<{ templates: Template[] }>('/templates');
|
||||||
return response.data || [];
|
return response.data.templates || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,7 +177,7 @@ export class SdkServer {
|
||||||
*/
|
*/
|
||||||
async getTemplate(templateCode: string): Promise<Template | null> {
|
async getTemplate(templateCode: string): Promise<Template | null> {
|
||||||
try {
|
try {
|
||||||
const response = await this.request<Template | null>(`/api/v1/templates/${templateCode}`);
|
const response = await this.request<Template | null>(`/templates/${templateCode}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Template ${templateCode} not found:`, error);
|
console.warn(`Template ${templateCode} not found:`, error);
|
||||||
|
|
@ -191,7 +191,7 @@ export class SdkServer {
|
||||||
*/
|
*/
|
||||||
async executeTemplate(params: ExecuteTemplateParams): Promise<string | null> {
|
async executeTemplate(params: ExecuteTemplateParams): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await this.request<string | any | null>(`/api/v1/templates/code/${params.templateCode}/execute`, 'POST', {
|
const response = await this.request<string | any | null>(`/templates/code/${params.templateCode}/execute`, 'POST', {
|
||||||
imageUrl: params.imageUrl,
|
imageUrl: params.imageUrl,
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
@ -212,7 +212,7 @@ export class SdkServer {
|
||||||
async login(params: any): Promise<{ tokens: IToken, user: IUser }> {
|
async login(params: any): Promise<{ tokens: IToken, user: IUser }> {
|
||||||
try {
|
try {
|
||||||
const platform = createPlatformFactory()
|
const platform = createPlatformFactory()
|
||||||
const response = await this.request<{ tokens: IToken, user: IUser }>(`/api/v1/users/login`, 'POST', {
|
const response = await this.request<{ tokens: IToken, user: IUser }>(`/login`, 'POST', {
|
||||||
platform: platform.getPlatform(),
|
platform: platform.getPlatform(),
|
||||||
code: params.code,
|
code: params.code,
|
||||||
encryptedData: params.encryptedData,
|
encryptedData: params.encryptedData,
|
||||||
|
|
@ -231,7 +231,7 @@ export class SdkServer {
|
||||||
*/
|
*/
|
||||||
async profile() {
|
async profile() {
|
||||||
try {
|
try {
|
||||||
const response = await this.request<{ tokens: IToken }>(`/api/v1/users/profile`, 'GET', {});
|
const response = await this.request<{ tokens: IToken }>(`/auth/google/userinfo`, 'GET', {});
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -243,7 +243,6 @@ export class SdkServer {
|
||||||
*/
|
*/
|
||||||
async getTemplatesByCodes(templateCodes: string[]): Promise<Template[]> {
|
async getTemplatesByCodes(templateCodes: string[]): Promise<Template[]> {
|
||||||
const templates: Template[] = [];
|
const templates: Template[] = [];
|
||||||
|
|
||||||
for (const code of templateCodes) {
|
for (const code of templateCodes) {
|
||||||
try {
|
try {
|
||||||
const template = await this.getTemplate(code);
|
const template = await this.getTemplate(code);
|
||||||
|
|
@ -254,7 +253,6 @@ export class SdkServer {
|
||||||
console.warn(`Failed to get template ${code}:`, error);
|
console.warn(`Failed to get template ${code}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates;
|
return templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +264,7 @@ export class SdkServer {
|
||||||
async getTaskProgress(taskId: string) {
|
async getTaskProgress(taskId: string) {
|
||||||
try {
|
try {
|
||||||
console.log(`task id is : ${taskId}`)
|
console.log(`task id is : ${taskId}`)
|
||||||
const response = await this.request<any>(`/api/v1/templates/execution/${taskId}/progress`, 'GET', {});
|
const response = await this.request<any>(`/templates/execution/${taskId}/progress`, 'GET', {});
|
||||||
console.log(`getTaskProgress response:`, response.data)
|
console.log(`getTaskProgress response:`, response.data)
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -279,8 +277,8 @@ export class SdkServer {
|
||||||
async getMineLogs() {
|
async getMineLogs() {
|
||||||
try {
|
try {
|
||||||
const userId = Taro.getStorageSync(TOKEN_UID_KEY);
|
const userId = Taro.getStorageSync(TOKEN_UID_KEY);
|
||||||
const response = await this.request<any[]>(`/api/v1/templates/executions/user/${userId}`, 'GET', {});
|
const response = await this.request<{ executions: any[] }>(`/templates/executions/user/${userId}`, 'GET', {});
|
||||||
return response.data
|
return response.data.executions
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue