feat: add useSubField hook
parent
e3fea3f815
commit
989fd4b30b
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,32 +86,27 @@ 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>
|
||||||
|
<div className='tooltip' data-tip={statInkKeyError ? t('密钥的长度应该为{{length}}, 请检查', { length: STAT_INK_KEY_LENGTH }) : null}>
|
||||||
<input
|
<input
|
||||||
className="input input-bordered w-full"
|
className={classNames("input input-bordered w-full", {
|
||||||
type="text"
|
'input-error': statInkKeyError,
|
||||||
placeholder={t('长度为43') ?? undefined}
|
|
||||||
value={value.profile.state.statInkApiKey ?? ''}
|
|
||||||
onChange={e => setValue({
|
|
||||||
...value,
|
|
||||||
profile: {
|
|
||||||
...value.profile,
|
|
||||||
state: {
|
|
||||||
...value.profile.state,
|
|
||||||
statInkApiKey: e.target.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
})}
|
||||||
|
type="text"
|
||||||
|
placeholder={t('请从stat.ink中获取API密钥') ?? undefined}
|
||||||
|
value={statInkApiKey.value ?? ''}
|
||||||
|
onChange={e => statInkApiKey.onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ErrorContent error={error} />
|
<ErrorContent error={error} />
|
||||||
<div className='flex gap-4 max-w-md justify-between flex-auto-all'>
|
<div className='flex gap-4 max-w-md justify-between flex-auto-all'>
|
||||||
<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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,23 @@ export class JSONRPCServer {
|
||||||
) => Response<any, ResponseError<32000, unknown>> = (id) =>
|
) => Response<any, ResponseError<32000, unknown>> = (id) =>
|
||||||
(
|
(
|
||||||
e,
|
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",
|
jsonrpc: "2.0",
|
||||||
id: id,
|
id: id,
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -83,7 +99,8 @@ export class JSONRPCServer {
|
||||||
message: "Internal error",
|
message: "Internal error",
|
||||||
data: String(e),
|
data: String(e),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// batch request
|
// batch request
|
||||||
if (Array.isArray(req)) {
|
if (Array.isArray(req)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue