feat: add useSubField hook

main
imspace 2023-03-09 06:38:39 +08:00
parent e3fea3f815
commit 989fd4b30b
9 changed files with 191 additions and 58 deletions

View File

@ -9,6 +9,6 @@ type HeaderProps = {
export const Header: React.FC<HeaderProps> = ({ title }) => {
const navigate = useNavigate();
return <>
<h2 className="card-title" data-tauri-drag-region><button onClick={() => navigate('/')}><AiOutlineLeft /></button>{title}</h2>
<h2 className="card-title" data-tauri-drag-region><button onClick={() => navigate(-1)}><AiOutlineLeft /></button>{title}</h2>
</>
}

View File

@ -3,7 +3,7 @@ import { usePromise } from 'hooks/usePromise';
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config';
import { run, useLog } from 'services/s3si';
import { addLog, run, useLog } from 'services/s3si';
import { Checkbox } from './Checkbox';
import { Loading } from './Loading';
@ -24,6 +24,10 @@ export const RunPanel: React.FC<RunPanelProps> = () => {
const onClick = async () => {
setLoading(true);
try {
addLog({
level: 'log',
msg: ['Export started at', new Date().toLocaleString()],
})
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink,file",
@ -35,14 +39,24 @@ export const RunPanel: React.FC<RunPanelProps> = () => {
...result,
state: newState,
})
} catch (e) {
console.error(e)
addLog({
level: 'error',
msg: [e],
})
} finally {
addLog({
level: 'log',
msg: ['Export ended at', new Date().toLocaleString()],
})
setLoading(false);
}
}
const disabled = !canExport(result);
return <>
<div className="tooltip" data-tip={disabled ? t('请先完成登录和stat.ink的API密钥设置') : undefined}>
<div className="tooltip" data-tip={disabled ? t('请先在设置中完成Nintendo Account登录和stat.ink的API密钥') : undefined}>
<Checkbox disabled={disabled || loading} value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button

View File

@ -0,0 +1,86 @@
type Maybe<T> = T | null | undefined;
type KeyOf<T extends Record<string, any>, K = keyof T> = K extends string ? (T[K] extends Function ? never : K) : never;
type DotField<T extends Maybe<Record<string, any>>, K = KeyOf<NonNullable<T>>> = K extends string
? K | `${K}.${DotField<NonNullable<T>[K]>}`
: never;
type ValueOf<T extends Record<string, any>, K> = K extends `${infer I}.${infer R}`
? ValueOf<NonNullable<T>[I], R>
: K extends string
? NonNullable<T>[K]
: never;
export type FormProps<T> = {
value: T;
onChange: (value: T) => void;
};
const pick = <T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> => {
const ret = {} as Pick<T, K>;
keys.forEach((key) => {
ret[key] = obj[key];
});
return ret;
};
export const mapFormProps = <T, U>(
formProps: FormProps<T>,
{ mapValue, mapOnChange }: {
mapValue: (v: T) => U;
mapOnChange: (v: U) => T;
},
): FormProps<U> => {
const { value, onChange } = formProps;
return {
value: mapValue(value),
onChange: (value: U) => onChange(mapOnChange(value)),
};
};
export const useSubField = <T extends Record<string, any>>({
value,
onChange,
}: {
value: T;
onChange?: (cb: (value: T) => T) => void;
}) => {
const subField = <K extends DotField<T>>(key: K): FormProps<ValueOf<T, K>> => {
const v = key.split('.').reduce((o, x) => (o ?? {})[x], value) as ValueOf<T, K>;
return {
value: v,
onChange: (v: ValueOf<T, K>) => {
const setInner = <O extends Record<string, any>>(o: O, k: string[], v: any): O => {
const [head, ...tail] = k;
let out;
if (tail.length === 0) {
out = {
...o,
[head]: v,
};
} else {
out = {
...o,
[head]: setInner(o[head], tail, v),
};
}
return out;
};
onChange?.((old) => setInner(old, key.split('.'), v));
},
};
};
const subKeys = <K extends keyof T>(keys: K[]) => {
return {
value: pick(value, keys),
onChange: (v: Pick<T, K>) => {
onChange?.((old) => ({
...old,
v,
}));
},
};
};
return {
subField,
subKeys,
};
};

View File

@ -10,6 +10,12 @@ import {
Transport,
} from "./types";
export class JSONRPCError extends Error {
constructor(public rpcError: ResponseError) {
super(rpcError.message);
}
}
export class JSONRPCClient<S extends Service> {
protected nextId = 1;
protected transport: Transport;
@ -103,7 +109,7 @@ export class JSONRPCClient<S extends Service> {
return new Promise<R>((res, rej) => {
this.requestMap.set(req.id, (result) => {
if (result.error) {
rej(result.error);
rej(new JSONRPCError(result.error));
} else {
res(result.result);
}
@ -114,9 +120,7 @@ export class JSONRPCClient<S extends Service> {
getProxy(): S {
const proxy = new Proxy({}, {
get: (_, method: string) => {
return (...params: unknown[]) => {
return this.call(method, ...params as any);
};
return (...params: unknown[]) => this.call(method, ...params as any);
},
});
return proxy as S;

View File

@ -58,7 +58,7 @@ export const Guide: React.FC = () => {
const steps: Step[] = [{
title: t('登录任天堂帐号'),
title: t('登录Nintendo Account'),
element: LoginNintendoAccount,
}, {
title: t('填写stat.ink API密钥'),
@ -69,7 +69,7 @@ export const Guide: React.FC = () => {
}]
return <div className="full-card">
<Header title={t('置向导')} />
<Header title={t('置向导')} />
<Steps className='mt-4' steps={steps} />
</div>
}

View File

@ -33,7 +33,7 @@ export const Home: React.FC = () => {
<div className='flex flex-col gap-2'>
<LogPanel className='sm:hidden max-h-[10rem]' />
<RunPanel />
<Link to='/settings' className='btn'>{t('置')}</Link>
<Link to='/settings' className='btn'>{t('置')}</Link>
<a className='btn' href={STAT_INK} target='_blank' rel='noreferrer'>{t('前往 stat.ink')}</a>
</div>
</div>

View File

@ -9,11 +9,15 @@ import classNames from 'classnames';
import { useLogin } from 'services/s3si';
import { STAT_INK } from 'constant';
import { Header } from 'components/Header';
import { useSubField } from 'hooks/useSubField';
import { useNavigate } from 'react-router-dom';
const STAT_INK_KEY_LENGTH = 43;
const Page: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
return <div className='full-card'>
<Header title={t('置')} />
<Header title={t('置')} />
{children}
</div>
}
@ -30,22 +34,12 @@ const Form: React.FC<{
const { login } = useLogin();
const { t } = useTranslation();
const [value, setValue] = useState(oldValue);
const { subField } = useSubField({ value, onChange: setValue });
const changed = JSON.stringify(value) !== JSON.stringify(oldValue);
const setSessionToken = (t: string) => setValue({
...value,
profile: {
...value.profile,
state: {
...value.profile.state,
loginState: {
...value.profile.state.loginState,
sessionToken: t,
},
}
}
})
const sessionToken = subField('profile.state.loginState.sessionToken')
const statInkApiKey = subField('profile.state.statInkApiKey')
const [onSave, { loading, error }] = usePromiseLazy(async () => {
await setProfile(0, value.profile);
@ -57,9 +51,11 @@ const Form: React.FC<{
if (!result) {
return;
}
setSessionToken(result.sessionToken);
sessionToken.onChange(result.sessionToken);
})
const statInkKeyError = (statInkApiKey.value?.length ?? STAT_INK_KEY_LENGTH) !== STAT_INK_KEY_LENGTH;
return <>
<div className='card'>
<div className="form-control w-full max-w-md mb-4">
@ -77,8 +73,8 @@ const Form: React.FC<{
className="input input-bordered w-full"
type="text"
placeholder={t('请点击右上角的登录填入') ?? undefined}
value={value.profile.state.loginState?.sessionToken ?? ''}
onChange={e => setSessionToken(e.target.value)}
value={sessionToken.value ?? ''}
onChange={e => sessionToken.onChange(e.target.value)}
/>
</div>
<div className="form-control w-full max-w-md mb-4">
@ -90,32 +86,27 @@ const Form: React.FC<{
rel='noopener noreferrer'
href={`${STAT_INK}/profile`}
title={t('打开 stat.ink') ?? undefined}
>{t('stat.ink')}</a></span>
>{t('查看API密钥')}</a></span>
</label>
<div className='tooltip' data-tip={statInkKeyError ? t('密钥的长度应该为{{length}}, 请检查', { length: STAT_INK_KEY_LENGTH }) : null}>
<input
className="input input-bordered w-full"
type="text"
placeholder={t('长度为43') ?? undefined}
value={value.profile.state.statInkApiKey ?? ''}
onChange={e => setValue({
...value,
profile: {
...value.profile,
state: {
...value.profile.state,
statInkApiKey: e.target.value,
}
}
className={classNames("input input-bordered w-full", {
'input-error': statInkKeyError,
})}
type="text"
placeholder={t('请从stat.ink中获取API密钥') ?? undefined}
value={statInkApiKey.value ?? ''}
onChange={e => statInkApiKey.onChange(e.target.value)}
/>
</div>
</div>
</div>
<ErrorContent error={error} />
<div className='flex gap-4 max-w-md justify-between flex-auto-all'>
<div className="tooltip" data-tip={changed ? undefined : t('没有更改')}>
<button className={classNames('btn btn-primary w-full', {
loading,
})} onClick={onSave} disabled={!changed}>{t('保存')}</button>
})} onClick={onSave} disabled={!changed || statInkKeyError}>{t('保存')}</button>
</div>
<button className={classNames('btn', {
loading,
@ -125,6 +116,7 @@ const Form: React.FC<{
}
export const Settings: React.FC = () => {
const navigate = useNavigate();
let { loading, error, retry, result } = composeLoadable({
config: usePromise(getConfig),
profile: usePromise(() => getProfile(0)),
@ -143,6 +135,6 @@ export const Settings: React.FC = () => {
}
return <Page>
{result && <Form oldValue={result} onSaved={retry} />}
{result && <Form oldValue={result} onSaved={() => navigate(-1)} />}
</Page>
}

View File

@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api";
import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc";
import { ExportOpts, Log, State } from "jsonrpc/types";
import { ExportOpts, Log, LoggerLevel, State } from "jsonrpc/types";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
const client = new JSONRPCClient<S3SIService>({
@ -39,6 +39,12 @@ async function getLogs() {
}
getLogs()
export function addLog(...log: Log[]) {
for (const cb of LOG_SUB) {
cb(log);
}
}
const LOG_CONTEXT = createContext<{
logs: Log[],
renderedLogs: React.ReactNode[]
@ -51,12 +57,26 @@ export const useLog = () => {
return useContext(LOG_CONTEXT);
}
function renderMsg(i: any) {
if (i instanceof Error) {
return i.message
}
return String(i)
}
const DISPLAY_MAP: Record<LoggerLevel, string> = {
debug: 'DEBUG',
log: 'INFO',
warn: 'WARN',
error: 'ERROR',
}
function renderLevel(log: Log) {
return `[${log.level.toUpperCase()}]`.padEnd(7)
return `[${DISPLAY_MAP[log.level]}]`.padEnd(7)
}
function renderLog(log: Log) {
return `${renderLevel(log)} ${log.msg.map(String).join(' ')}`
return `${renderLevel(log)} ${log.msg.map(renderMsg).join(' ')}`
}
export const LogProvider: React.FC<{ limit?: number, children?: React.ReactNode }> = ({ children, limit = 10 }) => {

View File

@ -75,7 +75,23 @@ export class JSONRPCServer {
) => Response<any, ResponseError<32000, unknown>> = (id) =>
(
e,
) => ({
) => {
if (e instanceof Error) {
return {
jsonrpc: "2.0",
id: id,
error: {
code: 32000,
message: e.message,
data: {
name: e.name,
stack: e.stack,
cause: e.cause,
},
},
};
}
return {
jsonrpc: "2.0",
id: id,
error: {
@ -83,7 +99,8 @@ export class JSONRPCServer {
message: "Internal error",
data: String(e),
},
});
};
};
// batch request
if (Array.isArray(req)) {