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 }) => { export const Header: React.FC<HeaderProps> = ({ title }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return <> 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 React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config'; 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 { Checkbox } from './Checkbox';
import { Loading } from './Loading'; import { Loading } from './Loading';
@ -24,6 +24,10 @@ export const RunPanel: React.FC<RunPanelProps> = () => {
const onClick = async () => { const onClick = async () => {
setLoading(true); setLoading(true);
try { try {
addLog({
level: 'log',
msg: ['Export started at', new Date().toLocaleString()],
})
const { state } = result; const { state } = result;
const newState = await run(state, { const newState = await run(state, {
exporter: "stat.ink,file", exporter: "stat.ink,file",
@ -35,14 +39,24 @@ export const RunPanel: React.FC<RunPanelProps> = () => {
...result, ...result,
state: newState, state: newState,
}) })
} catch (e) {
console.error(e)
addLog({
level: 'error',
msg: [e],
})
} finally { } finally {
addLog({
level: 'log',
msg: ['Export ended at', new Date().toLocaleString()],
})
setLoading(false); setLoading(false);
} }
} }
const disabled = !canExport(result); const disabled = !canExport(result);
return <> 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={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox> <Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button <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, Transport,
} from "./types"; } from "./types";
export class JSONRPCError extends Error {
constructor(public rpcError: ResponseError) {
super(rpcError.message);
}
}
export class JSONRPCClient<S extends Service> { export class JSONRPCClient<S extends Service> {
protected nextId = 1; protected nextId = 1;
protected transport: Transport; protected transport: Transport;
@ -103,7 +109,7 @@ export class JSONRPCClient<S extends Service> {
return new Promise<R>((res, rej) => { return new Promise<R>((res, rej) => {
this.requestMap.set(req.id, (result) => { this.requestMap.set(req.id, (result) => {
if (result.error) { if (result.error) {
rej(result.error); rej(new JSONRPCError(result.error));
} else { } else {
res(result.result); res(result.result);
} }
@ -114,9 +120,7 @@ export class JSONRPCClient<S extends Service> {
getProxy(): S { getProxy(): S {
const proxy = new Proxy({}, { const proxy = new Proxy({}, {
get: (_, method: string) => { get: (_, method: string) => {
return (...params: unknown[]) => { return (...params: unknown[]) => this.call(method, ...params as any);
return this.call(method, ...params as any);
};
}, },
}); });
return proxy as S; return proxy as S;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api"; import { invoke } from "@tauri-apps/api";
import { JSONRPCClient, S3SIService, StdioTransport } from "jsonrpc"; 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"; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
const client = new JSONRPCClient<S3SIService>({ const client = new JSONRPCClient<S3SIService>({
@ -39,6 +39,12 @@ async function getLogs() {
} }
getLogs() getLogs()
export function addLog(...log: Log[]) {
for (const cb of LOG_SUB) {
cb(log);
}
}
const LOG_CONTEXT = createContext<{ const LOG_CONTEXT = createContext<{
logs: Log[], logs: Log[],
renderedLogs: React.ReactNode[] renderedLogs: React.ReactNode[]
@ -51,12 +57,26 @@ export const useLog = () => {
return useContext(LOG_CONTEXT); 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) { function renderLevel(log: Log) {
return `[${log.level.toUpperCase()}]`.padEnd(7) return `[${DISPLAY_MAP[log.level]}]`.padEnd(7)
} }
function renderLog(log: Log) { 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 }) => { export const LogProvider: React.FC<{ limit?: number, children?: React.ReactNode }> = ({ children, limit = 10 }) => {

View File

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