Commit 0cb3e38c authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/online_deck' into 'main'

Neos接入Mdpro在线卡组功能

See merge request !381
parents e2380147 47be4466
......@@ -36,6 +36,7 @@
"athleticWatchUrl": "wss://tiramisu.moecube.com:8923",
"entertainWatchUrl": "wss://tiramisu.moecube.com:7923",
"userApi": "https://sapi.moecube.com:444/accounts/users/{username}.json",
"mdproServer": "https://rarnu.xyz:38443",
"streamInterval": 20,
"startDelay": 1000,
"ui": {
......
......@@ -36,6 +36,7 @@
"athleticWatchUrl": "wss://tiramisu.moecube.com:8923",
"entertainWatchUrl": "wss://tiramisu.moecube.com:7923",
"userApi": "https://sapi.moecube.com:444/accounts/users/{username}.json",
"mdproServer": "https://rarnu.xyz:38443",
"streamInterval": 20,
"startDelay": 1000,
"ui": {
......
export * from "./cards";
export * from "./cookies";
export * from "./forbiddens";
export * from "./mdproDeck";
export * from "./mycard";
export * from "./ocgcore/idl/ocgcore";
export * from "./ocgcore/ocgHelper";
......
export * from "./pull";
export * from "./update";
export * from "./upload";
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/list";
export interface PullReq {
page?: number;
size?: number;
keyWord?: string;
sortLike?: boolean;
sortRank?: boolean;
contributor?: string;
}
const defaultPullReq: PullReq = {
page: 1,
size: 20,
};
interface PullResp {
code: number;
message: string;
data?: {
current: number;
size: number;
total: number;
pages: number;
records: MdproDeck[];
};
}
export async function pullDecks(
req: PullReq = defaultPullReq,
): Promise<PullResp | undefined> {
const myHeaders = mdproHeaders();
const params = new URLSearchParams();
Object.entries(req).forEach(([key, value]) => {
if (value !== undefined) {
params.append(key, String(value));
}
});
const url = new URL(`${mdproServer}/${API_PATH}`);
url.search = params.toString();
const resp = await fetch(url.toString(), {
method: "GET",
headers: myHeaders,
redirect: "follow",
});
if (!resp.ok) {
console.error(`[Pull of Mdpro Decks] HTTPS error! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
}
export interface MdproDeck {
/*
*`ID` of the online deck.
* It is required when updating the deck, and optional
* when adding new deck. However, for the convenience,
* it is defined required here and it would be set to zero
* when adding new deck.
*/
deckId: string;
/* Contributor of the deck. */
deckContributor: string;
/* Name of the deck. */
deckName: string;
deckRank?: number;
deckLike?: number;
deckUploadDate?: string;
deckUpdateDate?: string;
/* Content of the deck. */
deckYdk: string;
deckCase: number;
}
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/update";
interface UpdateResp {
code: number;
message: string;
data: MdproDeck;
}
export async function updateDeck(
req: MdproDeck,
): Promise<UpdateResp | undefined> {
const myHeaders = mdproHeaders();
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "PUT",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
if (!resp.ok) {
console.error(`[Update of MdproDeck] HTTPS error! status: ${resp.status}`);
return undefined;
} else {
return await resp.json();
}
}
import { useConfig } from "@/config";
import { MdproDeck } from "./schema";
import { mdproHeaders } from "./util";
const { mdproServer } = useConfig();
const API_PATH = "api/mdpro3/deck/upload";
interface UploadResp {
code: number;
message: string;
data: MdproDeck;
}
export async function uploadDeck(
req: MdproDeck,
): Promise<UploadResp | undefined> {
const myHeaders = mdproHeaders();
myHeaders.append("Content-Type", "application/json");
const resp = await fetch(`${mdproServer}/${API_PATH}`, {
method: "POST",
headers: myHeaders,
body: JSON.stringify(req),
redirect: "follow",
});
if (!resp.ok) {
console.error(
`[Upload of Mdpro Decks] HTTPS error! status: ${resp.status}`,
);
return undefined;
} else {
return await resp.json();
}
}
export function mdproHeaders(): Headers {
const myHeaders = new Headers();
myHeaders.append("ReqSource", "MDPro3");
return myHeaders;
}
......@@ -26,11 +26,17 @@ export const deckStore = proxy({
const index = deckStore.decks.findIndex(
(deck) => deck.deckName === deckName,
);
if (index === -1) return false;
deckStore.decks[index] = deck;
await del(deckName, deckIdb); // 新的名字可能和旧的名字不一样,所以要删除旧的,再添加
await set(deck.deckName, deck, deckIdb);
return true;
if (index === -1) {
// if not existed, create one
await deckStore.add(deck);
return true;
} else {
deckStore.decks[index] = deck;
// 新的名字可能和旧的名字不一样,所以要删除旧的,再添加
await del(deckName, deckIdb);
await set(deck.deckName, deck, deckIdb);
return true;
}
},
async add(deck: IDeck): Promise<boolean> {
......
......@@ -14,7 +14,7 @@ import {
} from "@/common";
import { CardEffectText, IconFont, ScrollableArea, YgoCard } from "@/ui/Shared";
import styles from "./CardDetail.module.scss";
import styles from "./index.module.scss";
export const CardDetail: React.FC<{
code: number;
......
.search-cards {
--card-width: 5rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
padding: 0.75rem;
gap: 0.625rem;
}
.empty {
gap: 1.25rem;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
import { message, Pagination } from "antd";
import React, { memo, useEffect, useState } from "react";
import { CardMeta } from "@/api";
import { isExtraDeckCard } from "@/common";
import { DeckCard, DeckCardMouseUpEvent, IconFont } from "@/ui/Shared";
import { selectedCard } from "../..";
import { editDeckStore } from "../../store";
import styles from "./index.module.scss";
/** 搜索区的搜索结果,使用memo避免重复渲染 */
export const CardResults: React.FC<{
results: CardMeta[];
scrollToTop: () => void;
}> = memo(({ results, scrollToTop }) => {
const itemsPerPage = 196; // 每页显示的数据数量
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
setCurrentPage(1);
}, [results]);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentData = results.slice(startIndex, endIndex);
const showSelectedCard = (card: CardMeta) => {
selectedCard.id = card.id;
selectedCard.open = true;
};
const handleAddCardToMain = (card: CardMeta) => {
const cardType = card.data.type ?? 0;
const isExtraCard = isExtraDeckCard(cardType);
const type = isExtraCard ? "extra" : "main";
const { result, reason } = editDeckStore.canAdd(card, type, "search");
if (result) {
editDeckStore.add(type, card);
} else {
message.error(reason);
}
};
const handleAddCardToSide = (card: CardMeta) => {
const { result, reason } = editDeckStore.canAdd(card, "side", "search");
if (result) {
editDeckStore.add("side", card);
} else {
message.error(reason);
}
};
/** safari 不支持 onAuxClick,所以使用 mousedown 模拟 */
const handleMouseUp = (payload: DeckCardMouseUpEvent) => {
const { event, card } = payload;
switch (event.button) {
// 左键
case 0:
showSelectedCard(card);
break;
// 中键
case 1:
handleAddCardToSide(card);
break;
// 右键
case 2:
handleAddCardToMain(card);
break;
default:
break;
}
};
return (
<>
{results.length ? (
<>
<div className={styles["search-cards"]}>
{currentData.map((card) => (
<DeckCard
value={card}
key={card.id}
source="search"
onMouseUp={handleMouseUp}
onMouseEnter={() => showSelectedCard(card)}
/>
))}
</div>
{results.length > itemsPerPage && (
<div style={{ textAlign: "center", padding: "0.625rem 0 1.25rem" }}>
<Pagination
current={currentPage}
onChange={(page) => {
setCurrentPage(page);
scrollToTop();
}}
total={results.length}
pageSize={itemsPerPage}
showSizeChanger={false}
showLessItems
hideOnSinglePage
/>
</div>
)}
</>
) : (
<div className={styles.empty}>
<IconFont type="icon-empty" size={40} />
<div>找不到相应卡片</div>
</div>
)}
</>
);
});
.container {
width: 100%;
}
.search-decks {
--deck-width: 8rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--deck-width), 1fr));
padding: 0.75rem;
gap: 0.5rem;
}
.mdpro-deck {
position: relative;
display: flex;
width: 8rem;
height: 8rem;
background-color: black;
justify-content: center;
align-items: center;
flex-direction: column;
border: 1px solid grey;
border-radius: 10% 2%;
cursor: pointer;
&:hover {
background-color: grey;
}
img {
width: 4rem;
}
.text {
text-align: center;
font-size: 0.8rem;
}
}
.copy-btn {
background-color: #4B4B4B;
}
.empty {
gap: 1.25rem;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
import { message, Pagination } from "antd";
import React, { memo, useEffect } from "react";
import { type INTERNAL_Snapshot as Snapshot, proxy, useSnapshot } from "valtio";
import YGOProDeck from "ygopro-deck-encode";
import { pullDecks } from "@/api";
import { MdproDeck } from "@/api/mdproDeck/schema";
import { useConfig } from "@/config";
import { IconFont } from "@/ui/Shared";
import { setSelectedDeck } from "../..";
import { editDeckStore } from "../../store";
import { iDeckToEditingDeck } from "../../utils";
import styles from "./index.module.scss";
const { assetsPath } = useConfig();
interface Props {
query: string;
page: number;
decks: MdproDeck[];
total: number;
}
// TODO: useConfig
const PAGE_SIZE = 30;
const SORT_LIKE = true;
const store = proxy<Props>({ query: "", page: 1, decks: [], total: 0 });
export const DeckResults: React.FC = memo(() => {
const snap = useSnapshot(store);
useEffect(() => {
const update = async () => {
const resp = await pullDecks({
page: snap.page,
size: PAGE_SIZE,
keyWord: snap.query !== "" ? snap.query : undefined,
sortLike: SORT_LIKE,
});
if (resp?.data) {
const { total, records: newDecks } = resp.data;
store.total = total;
store.decks = newDecks;
} else {
store.decks = [];
}
};
update();
}, [snap.query, snap.page]);
const onChangePage = async (page: number) => {
const resp = await pullDecks({
page,
size: PAGE_SIZE,
keyWord: store.query !== "" ? store.query : undefined,
sortLike: SORT_LIKE,
});
if (resp?.data) {
const { current, total, records } = resp.data;
store.page = current;
store.total = total;
store.decks = records;
} else if (resp?.code !== 0) {
message.error(resp?.message);
} else {
message.error("翻页失败,请检查您的网络状况。");
}
};
return (
<>
{snap.decks.length ? (
<div className={styles.container}>
<div className={styles["search-decks"]}>
{snap.decks.map((deck) => (
<MdproDeckBlock key={deck.deckId} {...deck} />
))}
</div>
<div style={{ textAlign: "center", padding: "0.625rem 0 1.25rem" }}>
<Pagination
current={snap.page}
onChange={onChangePage}
total={snap.total}
pageSize={PAGE_SIZE}
showLessItems
hideOnSinglePage
/>
</div>
</div>
) : (
<div className={styles.empty}>
<IconFont type="icon-empty" size={40} />
<div>找不到相应卡组</div>
</div>
)}
</>
);
});
const MdproDeckBlock: React.FC<Snapshot<MdproDeck>> = (deck) => (
<div
className={styles["mdpro-deck"]}
onClick={async () => await copyMdproDeckToEditing(deck)}
>
<img
src={`${assetsPath}/deck-cases/DeckCase${deck.deckCase
.toString()
.slice(-4)}_L.png`}
/>
<div className={styles.text}>
<div>{truncateString(deck.deckName, 8)}</div>
<div>{`By ${truncateString(deck.deckContributor, 6)}`}</div>
</div>
</div>
);
const copyMdproDeckToEditing = async (mdproDeck: MdproDeck) => {
const deck = YGOProDeck.fromYdkString(mdproDeck.deckYdk);
if (!(deck.main.length + deck.extra.length + deck.side.length === 0)) {
const deckName = mdproDeck.deckName;
const ideck = { deckName, ...deck };
const editingDeck = await iDeckToEditingDeck(ideck);
setSelectedDeck(ideck);
editDeckStore.set(editingDeck);
} else {
message.error("卡组解析失败,请联系技术人员解决:<ccc@neos.moe>");
}
};
function truncateString(str: string, maxLen: number): string {
const length = Array.from(str).length;
if (length <= maxLen) {
return str;
}
const start = Array.from(str).slice(0, 3).join("");
const end = Array.from(str).slice(-3).join("");
return `${start}...${end}`;
}
export const freshMdrpoDecks = (query: string) => (store.query = query);
import {
DeleteOutlined,
FilterOutlined,
SearchOutlined,
SortAscendingOutlined,
SwapOutlined,
} from "@ant-design/icons";
import { App, Button, Dropdown, Input, Space } from "antd";
import { MenuProps } from "antd/lib";
import { isEqual } from "lodash-es";
import { OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDrop } from "react-dnd";
import { CardMeta, searchCards } from "@/api";
import { isToken } from "@/common";
import { emptySearchConditions, FtsConditions } from "@/middleware/sqlite/fts";
import { ScrollableArea, Type } from "@/ui/Shared";
import { Filter } from "../Filter";
import styles from "../index.module.scss";
import { editDeckStore } from "../store";
import { CardResults } from "./CardResults";
import { DeckResults, freshMdrpoDecks } from "./DeckResults";
/** 卡片库,选择卡片加入正在编辑的卡组 */
export const DeckDatabase: React.FC = () => {
const { modal } = App.useApp();
const [searchWord, setSearchWord] = useState("");
const [searchConditions, setSearchConditions] = useState<FtsConditions>(
emptySearchConditions,
);
const [searchCardResult, setSearchCardResult] = useState<CardMeta[]>([]);
const defaultSort = (a: CardMeta, b: CardMeta) => a.id - b.id;
const sortRef = useRef<(a: CardMeta, b: CardMeta) => number>(defaultSort);
const [sortEdited, setSortEdited] = useState(false);
const [showMdproDecks, setShowMdproDecks] = useState(false);
const setSortRef = (sort: (a: CardMeta, b: CardMeta) => number) => {
sortRef.current = sort;
setSearchCardResult([...searchCardResult.sort(sortRef.current)]);
setSortEdited(true);
};
const genSort = (key: keyof CardMeta["data"], scale: 1 | -1 = 1) => {
return () =>
setSortRef(
(a: CardMeta, b: CardMeta) =>
((a.data?.[key] ?? 0) - (b.data?.[key] ?? 0)) * scale,
);
};
const dropdownOptions: MenuProps["items"] = (
[
["从新到旧", () => setSortRef((a, b) => b.id - a.id)],
["从旧到新", () => setSortRef((a, b) => a.id - b.id)],
["攻击力从高到低", genSort("atk", -1)],
["攻击力从低到高", genSort("atk")],
["守备力从高到低", genSort("def", -1)],
["守备力从低到高", genSort("def")],
["星/阶/刻/Link从高到低", genSort("level", -1)],
["星/阶/刻/Link从低到高", genSort("level")],
["灵摆刻度从高到低", genSort("lscale", -1)],
["灵摆刻度从低到高", genSort("lscale")],
] as const
).map(([label, onClick], key) => ({ key, label, onClick }));
const handleSearch = (conditions: FtsConditions = searchConditions) => {
if (showMdproDecks) {
freshMdrpoDecks(searchWord);
} else {
const result = searchCards({ query: searchWord, conditions })
.filter((card) => !isToken(card.data.type ?? 0))
.sort(sortRef.current); // 衍生物不显示
setSearchCardResult(() => result);
}
};
useEffect(() => {
handleSearch();
}, []);
const [_, dropRef] = useDrop({
accept: ["Card"], // 指明该区域允许接收的拖放物。可以是单个,也可以是数组
// 里面的值就是useDrag所定义的type
// 当拖拽物在这个拖放区域放下时触发,这个item就是拖拽物的item(拖拽物携带的数据)
drop: ({ value, source }: { value: CardMeta; source: Type | "search" }) => {
if (source !== "search") {
editDeckStore.remove(source, value);
}
},
});
const showFilterModal = () => {
const { destroy } = modal.info({
width: 500,
centered: true,
title: null,
icon: null,
content: (
<Filter
conditions={searchConditions}
onConfirm={(newConditions) => {
setSearchConditions(newConditions);
destroy();
setTimeout(() => handleSearch(newConditions), 200); // 先收起再搜索
}}
onCancel={() => destroy()}
/>
),
footer: null,
});
};
/** 滚动条的ref,用来在翻页之后快速回顶 */
const ref = useRef<OverlayScrollbarsComponentRef<"div">>(null);
const scrollToTop = useCallback(() => {
const viewport = ref.current?.osInstance()?.elements().viewport;
if (viewport) viewport.scrollTop = 0;
}, []);
return (
<div className={styles.container} ref={dropRef}>
<Space className={styles.title} direction="horizontal">
<Input
placeholder="关键词(空格分隔)"
bordered={false}
suffix={
<Button
type="text"
icon={<SearchOutlined />}
onClick={() => handleSearch()}
/>
}
value={searchWord}
onChange={(e) => setSearchWord(e.target.value)}
onKeyUp={(e) => e.key === "Enter" && handleSearch()}
allowClear
style={{ width: "250%" }}
/>
<Button
style={{ marginRight: "1rem" }}
icon={<SwapOutlined />}
onClick={() => setShowMdproDecks(!showMdproDecks)}
>
{showMdproDecks ? "卡片数据库" : "Mdpro在线卡组"}
</Button>
</Space>
<div className={styles["select-btns"]}>
<Button
block
type={
isEqual(emptySearchConditions, searchConditions)
? "text"
: "primary"
}
disabled={showMdproDecks}
icon={<FilterOutlined />}
onClick={showFilterModal}
>
筛选
</Button>
<Dropdown
menu={{ items: dropdownOptions }}
disabled={showMdproDecks}
trigger={["click"]}
placement="bottom"
arrow
>
<Button
block
type={sortEdited ? "primary" : "text"}
icon={<SortAscendingOutlined />}
>
<span>
排列
<span className={styles["search-count"]}>
({searchCardResult.length})
</span>
</span>
</Button>
</Dropdown>
<Button
block
type="text"
disabled={showMdproDecks}
icon={<DeleteOutlined />}
onClick={() => {
setSearchConditions(emptySearchConditions);
setSortRef(defaultSort);
setSortEdited(false);
handleSearch(emptySearchConditions);
}}
>
重置
</Button>
</div>
<ScrollableArea className={styles["search-cards-container"]} ref={ref}>
{showMdproDecks ? (
<DeckResults />
) : (
<CardResults results={searchCardResult} scrollToTop={scrollToTop} />
)}
</ScrollableArea>
</div>
);
};
......@@ -4,18 +4,24 @@ import {
DownloadOutlined,
FileAddOutlined,
PlusOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { App, Button, Dropdown, MenuProps, UploadProps } from "antd";
import React, { useRef, useState } from "react";
import YGOProDeck from "ygopro-deck-encode";
import { deckStore, IDeck } from "@/stores";
import { uploadDeck } from "@/api";
import { MdproDeck } from "@/api/mdproDeck/schema";
import { accountStore, deckStore, IDeck } from "@/stores";
import { Uploader } from "../Shared";
import styles from "./DeckSelect.module.scss";
import { Uploader } from "../../Shared";
import { genYdkText } from "../utils";
import styles from "./index.module.scss";
const DEFAULT_DECK_CASE = 1082012;
export const DeckSelect: React.FC<{
decks: readonly { deckName: string }[];
decks: IDeck[];
selected: string;
onSelect: (deckName: string) => any;
onDelete: (deckName: string) => Promise<any>;
......@@ -120,25 +126,52 @@ export const DeckSelect: React.FC<{
},
].map((_, key) => ({ ..._, key }));
const onUploadMdDeck = async (deck: IDeck) => {
const user = accountStore.user;
if (user) {
// TODO: Deck Case
const mdproDeck: MdproDeck = {
deckId: "",
deckContributor: user.username,
deckName: deck.deckName,
deckYdk: genYdkText(deck),
deckCase: DEFAULT_DECK_CASE,
};
const resp = await uploadDeck(mdproDeck);
if (resp) {
if (resp.code) {
message.error(resp.message);
} else {
message.success(`上传在线卡组<${deck.deckName}>成功!`);
}
} else {
message.error("上传在线卡组失败,请检查网络状况。");
}
} else {
message.error("需要先登录萌卡账号才能上传在线卡组!");
}
};
return (
<>
<div className={styles["deck-select"]}>
{decks.map(({ deckName }) => (
{decks.map((deck) => (
<div
key={deckName}
key={deck.deckName}
className={styles.item}
onClick={() => onSelect(deckName)}
onClick={() => onSelect(deck.deckName)}
>
<div className={styles.hover} />
{selected === deckName && <div className={styles.selected} />}
<span>{deckName}</span>
{selected === deck.deckName && <div className={styles.selected} />}
<span>{deck.deckName}</span>
<div className={styles.btns}>
<Button
icon={<CopyOutlined />}
type="text"
size="small"
onClick={cancelBubble(async () => {
const result = await onCopy(deckName);
const result = await onCopy(deck.deckName);
result
? message.success("复制成功")
: message.error("复制失败");
......@@ -149,7 +182,7 @@ export const DeckSelect: React.FC<{
type="text"
size="small"
onClick={cancelBubble(async () => {
await onDelete(deckName);
await onDelete(deck.deckName);
onSelect(decks[0].deckName);
})}
/>
......@@ -158,7 +191,13 @@ export const DeckSelect: React.FC<{
icon={<DownloadOutlined />}
type="text"
size="small"
onClick={cancelBubble(() => onDownload(deckName))}
onClick={cancelBubble(() => onDownload(deck.deckName))}
/>
<Button
icon={<UploadOutlined />}
type="text"
size="small"
onClick={cancelBubble(async () => onUploadMdDeck(deck))}
/>
</div>
</div>
......
......@@ -16,7 +16,7 @@ import {
} from "@/common";
import { FtsConditions } from "@/middleware/sqlite/fts";
import styles from "./Filter.module.scss";
import styles from "./index.module.scss";
const levels = Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
......
......@@ -74,29 +74,12 @@
.search-cards-container {
height: 100%;
.search-cards {
--card-width: 5rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width), 1fr));
padding: 0.75rem;
gap: 0.625rem;
}
}
.search-count {
font-size: 0.7rem;
}
.empty {
gap: 1.25rem;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.editing-zone-name {
position: absolute;
right: 0;
......
This diff is collapsed.
......@@ -38,7 +38,7 @@ export const compareCards = (a: CardMeta, b: CardMeta): number => {
};
/** 生成ydk格式的卡组文本 */
function genYdkText(deck: IDeck): string {
export function genYdkText(deck: IDeck): string {
const { main, extra, side } = deck;
const lines = [
......
......@@ -3,6 +3,7 @@ import { LoaderFunction, useNavigate, useSearchParams } from "react-router-dom";
import { useSnapshot } from "valtio";
import { ygopro } from "@/api";
import { useEnv } from "@/hook";
import { AudioActionType, changeScene } from "@/infra/audio";
import { matStore, SideStage, sideStore } from "@/stores";
......@@ -24,7 +25,6 @@ import { AnnounceModal } from "./Message/AnnounceModal";
import { LifeBar, Mat, Menu, Underlying } from "./PlayMat";
import { ChatBox } from "./PlayMat/ChatBox";
import { HandChain } from "./PlayMat/HandChain";
import { useEnv } from "@/hook";
export const loader: LoaderFunction = async () => {
// 更新场景
......
......@@ -3,11 +3,11 @@ import React, { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { proxy, useSnapshot } from "valtio";
import { useEnv } from "@/hook";
import { replayStore } from "@/stores";
import { Uploader } from "../../Shared";
import { connectSrvpro } from "../util";
import { useEnv } from "@/hook";
const localStore = proxy({
open: false,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment