feat: add basic export

main
imspace 2023-03-09 01:03:57 +08:00
parent 16a4546710
commit 77c621b499
11 changed files with 272 additions and 25 deletions

View File

@ -85,11 +85,11 @@
"resizable": true,
"title": "s3si.ts",
"width": 400,
"height": 300,
"height": 400,
"minWidth": 320,
"minHeight": 300,
"minHeight": 400,
"visible": false
}
]
}
}
}

View File

@ -0,0 +1,16 @@
import React from 'react'
type CheckboxProps = {
children?: React.ReactNode
value?: boolean
onChange?: (value: boolean) => void
}
export const Checkbox: React.FC<CheckboxProps> = ({ value, onChange, children }) => {
return <div className="form-control">
<label className="label cursor-pointer">
<span className="label-text">{children}</span>
<input type="checkbox" checked={value ?? false} onChange={() => onChange?.(!value)} className="checkbox" />
</label>
</div>
}

View File

@ -0,0 +1,54 @@
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config';
import { run } from 'services/s3si';
import { Checkbox } from './Checkbox';
import { Loading } from './Loading';
type RunPanelProps = {
}
export const RunPanel: React.FC<RunPanelProps> = () => {
const { t } = useTranslation();
const { result } = usePromise(() => getProfile(0));
const [exportBattle, setExportBattle] = useState(true);
const [exportCoop, setExportCoop] = useState(true);
const [loading, setLoading] = useState(false);
if (!result) {
return <Loading />
}
const onClick = async () => {
setLoading(true);
try {
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink,file",
monitor: false,
withSummary: false,
skipMode: exportBattle === false ? 'vs' : exportCoop === false ? 'coop' : undefined,
});
await setProfile(0, {
...result,
state: newState,
})
} finally {
setLoading(false);
}
}
return <>
<Checkbox value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button
onClick={onClick}
className={classNames('btn', {
'btn-disabled': !canExport(result) || (!exportBattle && !exportCoop),
'loading': loading,
})}
>{t('导出')}</button>
</>
}

View File

@ -1 +1 @@
export * from '../../../src/jsonrpc/types';
export type * from '../../../src/jsonrpc/types';

View File

@ -1,3 +1,75 @@
import classNames from 'classnames';
import { Header } from 'components/Header';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type StepState = {
next: boolean,
prev: boolean,
}
type Step = {
title: string,
element: React.FC<{ onChange: (v: StepState) => void }>,
}
const Steps: React.FC<{ steps: Step[], className?: string }> = ({ className, steps }) => {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [state, setState] = useState({ next: true, prev: true });
const hasPrev = step > 0;
const hasNext = step < steps.length - 1;
const Content = steps[step].element;
return <div className={`flex flex-col items-center ${className}`}>
{/* <ul className="steps w-full mb-4">
{steps.map(({ title }, i) => <li key={i} className={classNames("step", {
'step-primary': i <= step,
})}>{title}</li>)}
</ul> */}
{Content && <Content onChange={setState} />}
<div className='mt-4 flex gap-2'>
<button
onClick={() => setStep(s => s - 1)}
className={classNames('btn', {
'btn-disabled': !hasPrev || !state.prev,
})}
>{t('上一步')}</button>
<button
onClick={() => setStep(s => s + 1)}
className={classNames('btn', {
'btn-disabled': !hasNext || !state.next,
})}
>{t('下一步')}</button>
</div>
</div>
}
const LoginNintendoAccount: React.FC<{ onChange: (v: StepState) => void }> = ({ onChange }) => {
const { t } = useTranslation();
return <div className='my-3'>
<button className='btn' onClick={() => onChange({ next: true, prev: true })}>{t('点击登录')}</button>
</div>
}
export const Guide: React.FC = () => {
return <></>
}
const { t } = useTranslation();
const steps: Step[] = [{
title: t('登录任天堂帐号'),
element: LoginNintendoAccount,
}, {
title: t('填写stat.ink API密钥'),
element: () => <></>,
}, {
title: t('完成'),
element: () => <></>,
}]
return <div className="full-card">
<Header title={t('配置向导')} />
<Steps className='mt-4' steps={steps} />
</div>
}

View File

@ -1,6 +1,6 @@
import { CheckUpdate } from 'components/CheckUpdate';
import { ErrorContent } from 'components/ErrorContent';
import { Loading } from 'components/Loading';
import { RunPanel } from 'components/RunPanel';
import { STAT_INK } from 'constant';
import { usePromise } from 'hooks/usePromise';
import React from 'react'
@ -32,9 +32,9 @@ export const Home: React.FC = () => {
<div className='full-card'>
<h1 className='mb-4'>{t('欢迎!')}</h1>
<div className='flex flex-col gap-2'>
<Link to='/settings' className='btn btn-primary'>{t('配置')}</Link>
<RunPanel />
<Link to='/settings' className='btn'>{t('配置')}</Link>
<a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a>
<CheckUpdate className='btn'>{t('检查更新')}</CheckUpdate>
</div>
</div>
</>

View File

@ -58,3 +58,7 @@ export async function getProfile(index: number): Promise<Profile> {
export async function setProfile(index: number, profile: Profile) {
await fs.writeTextFile(await profileDir.then(c => join(c, `${index}.json`)), JSON.stringify(profile));
}
export function canExport(profile: Profile): boolean {
return !!profile.state.loginState?.sessionToken
}

View File

@ -1,11 +1,40 @@
import { invoke } from "@tauri-apps/api";
import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc";
import { ExportOpts, State } from "jsonrpc/types";
import { useCallback } from "react";
const client = new JSONRPCClient<S3SIService>({
transport: new StdioTransport()
}).getProxy();
async function getLogs() {
while (true) {
const r = await client.getLogs()
if (r.error) {
throw new Error(r.error.message);
}
for (const { level, msg } of r.result) {
switch (level) {
case 'debug':
console.debug(...msg);
break;
case 'log':
console.log(...msg);
break;
case 'warn':
console.warn(...msg);
break;
case 'error':
console.error(...msg);
break;
}
}
}
}
getLogs()
export const useLogin = () => {
const login = useCallback(async () => {
const result = await client.loginSteps();
@ -35,3 +64,11 @@ export const useLogin = () => {
login
}
}
export async function run(state: State, opts: ExportOpts) {
const r = await client.run(state, opts);
if (r.error) {
throw new Error(r.error.message);
}
return r.result;
}

View File

@ -8,20 +8,17 @@ import { DenoIO } from "./jsonrpc/deno.ts";
import { loginSteps } from "./iksm.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { Queue } from "./jsonrpc/channel.ts";
enum LoggerLevel {
Debug = "debug",
Log = "log",
Warn = "warn",
Error = "error",
}
import { ExportOpts, Log } from "./jsonrpc/types.ts";
import { App } from "./app.ts";
import { InMemoryStateBackend, State } from "./state.ts";
import { MemoryCache } from "./cache.ts";
class S3SIServiceImplement implements S3SIService, Service {
loginMap: Map<string, {
step1: (url: string) => void;
promise: Promise<string>;
}> = new Map();
loggerQueue: Queue<{ level: LoggerLevel; msg: unknown[] }> = new Queue();
loggerQueue: Queue<Log> = new Queue();
env: Env = {
prompts: {
promptLogin: () => {
@ -32,12 +29,10 @@ class S3SIServiceImplement implements S3SIService, Service {
},
},
logger: {
debug: (...msg) =>
this.loggerQueue.push({ level: LoggerLevel.Debug, msg }),
log: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Log, msg }),
warn: (...msg) => this.loggerQueue.push({ level: LoggerLevel.Warn, msg }),
error: (...msg) =>
this.loggerQueue.push({ level: LoggerLevel.Error, msg }),
debug: (...msg) => this.loggerQueue.push({ level: "debug", msg }),
log: (...msg) => this.loggerQueue.push({ level: "log", msg }),
warn: (...msg) => this.loggerQueue.push({ level: "warn", msg }),
error: (...msg) => this.loggerQueue.push({ level: "error", msg }),
},
newFetcher: DEFAULT_ENV.newFetcher,
};
@ -76,6 +71,28 @@ class S3SIServiceImplement implements S3SIService, Service {
result: await loginSteps(this.env, step2),
};
}
async getLogs(): Promise<RPCResult<Log[]>> {
const log = await this.loggerQueue.pop();
return {
result: log ? [log] : [],
};
}
async run(state: State, opts: ExportOpts): Promise<RPCResult<State>> {
const stateBackend = new InMemoryStateBackend(state);
const app = new App({
...opts,
noProgress: true,
env: this.env,
profilePath: "",
stateBackend,
cache: new MemoryCache(),
});
await app.run();
return {
result: stateBackend.state,
};
}
// deno-lint-ignore no-explicit-any
[key: string]: any;
}

View File

@ -1,6 +1,6 @@
export class Queue<T> {
queue: T[] = [];
waiting: ((value: T | undefined) => void)[] = [];
private queue: T[] = [];
private waiting: ((value: T | undefined) => void)[] = [];
pop = (): Promise<T | undefined> => {
return new Promise<T | undefined>((resolve) => {

View File

@ -1,3 +1,34 @@
export type LoginState = {
sessionToken?: string;
gToken?: string;
bulletToken?: string;
};
export type RankState = {
// generated by gameId(battle.id)
gameId: string;
// extract from battle.id
timestamp?: number;
// C-, B, A+, S, S+0, S+12
rank: string;
rankPoint: number;
};
export type State = {
loginState?: LoginState;
fGen: string;
appUserAgent?: string;
userLang?: string;
userCountry?: string;
rankState?: RankState;
cacheDir: string;
// Exporter config
statInkApiKey?: string;
fileExportPath: string;
monitorInterval: number;
};
export type ID = string | number | null;
// deno-lint-ignore no-explicit-any
@ -68,6 +99,20 @@ export const ERROR_INTERNAL_ERROR: ResponseError<-32603> = {
message: "Internal error",
};
export type LoggerLevel = "debug" | "log" | "warn" | "error";
export type Log = {
level: LoggerLevel;
msg: unknown[];
};
export type ExportOpts = {
exporter: string;
monitor: boolean;
withSummary: boolean;
skipMode?: string;
};
export interface S3SIService {
loginSteps(): Promise<
RPCResult<
@ -87,6 +132,8 @@ export interface S3SIService {
}
>
>;
getLogs(): Promise<RPCResult<Log[]>>;
run(state: State, opts: ExportOpts): Promise<RPCResult<State>>;
// deno-lint-ignore no-explicit-any
[key: string]: any;
}