[feat] user lists fit for many users and 3 display mode

This commit is contained in:
2025-11-06 03:55:37 +08:00
parent 8eeb7347a2
commit 72eef189da
2 changed files with 399 additions and 65 deletions

View File

@@ -1,15 +1,41 @@
import { Refresh, User, FolderFocusOne, Login, Check } from "@icon-park/react"
import { Refresh, User, FolderFocusOne, Login, Check, List, GridFour } from "@icon-park/react"
import { Card, CardBody, CardHeader, CardIcon, CardTool } from "../window/Card"
import { addToast, Button, Chip } from "@heroui/react"
import { addToast, Button, Chip, Tabs, Tab, Tooltip } from "@heroui/react"
import { useSteamStore } from "@/store/steam"
import { useAppStore } from "@/store/app"
import { ToolButton } from "../window/ToolButton"
import { useAutoAnimate } from "@formkit/auto-animate/react"
import { invoke } from "@tauri-apps/api/core"
import { writeText } from "@tauri-apps/plugin-clipboard-manager"
import path from "path"
import { useState, useMemo } from "react"
import type { SteamUser } from "@/types/steam"
type ViewMode = "card" | "list" | "list-large"
const SteamUsers = ({ className }: { className?: string }) => {
const steam = useSteamStore()
const app = useAppStore()
const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
const viewMode = app.state.steamUsersViewMode as ViewMode
const [mockUsers, setMockUsers] = useState<SteamUser[] | null>(null)
// 生成模拟用户数据
const generateMockUsers = (): SteamUser[] => {
return Array.from({ length: 200 }, (_, i) => ({
steam_id64: BigInt(76561198000000000 + i),
steam_id32: 1000000 + i,
account_name: `mockuser${i + 1}`,
persona_name: `模拟用户 ${i + 1}`,
recent: i < 3 ? 1 : 0,
avatar: "",
}))
}
// 使用模拟数据或真实数据
const displayUsers = useMemo(() => {
return mockUsers || steam.state.users
}, [mockUsers, steam.state.users])
const getUsers = async (toast?: boolean) => {
if (!steam.state.steamDirValid) {
@@ -21,81 +47,382 @@ const SteamUsers = ({ className }: { className?: string }) => {
if (toast) addToast({ title: `已获取Steam用户` })
}
const handleMockToggle = () => {
if (mockUsers) {
setMockUsers(null)
addToast({ title: "已切换回真实数据" })
} else {
setMockUsers(generateMockUsers())
addToast({ title: "已切换到模拟数据200个用户" })
}
}
// 复制到剪贴板的辅助函数
const handleCopy = async (text: string, label: string) => {
try {
await writeText(text)
addToast({ title: `已复制${label}` })
} catch (error) {
addToast({ title: "复制失败", color: "danger" })
}
}
// 渲染用户项 - 列表-大样式(当前样式)
const renderListLargeItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex gap-2 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-20 h-20 rounded-l-lg"
draggable="false"
/>
<div className="flex flex-col grow justify-center gap-2 p-0.5">
<h3
className="text-xl font-semibold cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
<div className="flex gap-2">
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</Chip>
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</Chip>
<Chip
size="sm"
className="cursor-pointer bg-zinc-200 dark:bg-zinc-800 hover:opacity-80"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</Chip>
{user.recent > 0 && (
<Chip size="sm" color="primary">
</Chip>
)}
</div>
</div>
<div className="flex items-end gap-2 p-2 shrink-0">
<Button
size="sm"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="gap-1"
>
CFG
</Button>
<Button
size="sm"
onPress={() => steam.switchLoginUser(id)}
className="gap-1"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
onPress={() => steam.selectUser(id)}
className="gap-1"
isDisabled={isMock}
>
<Check size={14} />
</Button>
</div>
</li>
)
// 渲染用户项 - 列表样式(缩小版)
const renderListItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex gap-2 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="rounded-l-lg w-14 h-14"
draggable="false"
/>
<div className="flex flex-col grow justify-center gap-1.5 p-1.5">
<div className="flex items-center gap-2">
<h3
className="text-base font-semibold cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
{user.recent > 0 && (
<Chip size="sm" color="primary" className="text-[10px] px-1 h-5">
</Chip>
)}
</div>
<div className="flex gap-1.5 flex-wrap items-center">
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</span>
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</span>
<span
className="text-xs cursor-pointer text-zinc-600 dark:text-zinc-400 hover:underline hover:decoration-zinc-300 dark:hover:decoration-zinc-600"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</span>
</div>
</div>
<div className="flex items-center gap-2 p-1.5 mr-2">
<Button
size="sm"
radius="full"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="gap-1 px-3 h-7"
>
CFG
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.switchLoginUser(id)}
className="gap-1 px-3 h-7"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.selectUser(id)}
className="gap-1 px-3 h-7"
isDisabled={isMock}
>
<Check size={12} />
</Button>
</div>
</li>
)
// 渲染用户项 - 卡片样式grid布局
const renderCardItem = (user: SteamUser, id: number, isMock: boolean) => (
<li
key={`${user.account_name}-${id}`}
className="flex flex-col gap-2 p-3 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50 h-fit"
>
<div className="flex items-center gap-2">
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-12 h-12 rounded-lg shrink-0"
draggable="false"
/>
<div className="flex flex-col min-w-0 grow">
<div className="flex items-center gap-2">
<h3
className="text-sm font-semibold leading-relaxed truncate cursor-pointer hover:underline"
onClick={() => handleCopy(user.persona_name, "用户名")}
>
{user.persona_name}
</h3>
{user.recent > 0 && (
<Chip size="sm" color="primary" className="text-[10px] px-1 h-5 shrink-0">
</Chip>
)}
</div>
<p
className="text-xs leading-normal truncate cursor-pointer text-zinc-500 hover:underline"
onClick={() => handleCopy(user.account_name, "账号名")}
>
{user.account_name}
</p>
</div>
</div>
<div className="flex gap-1.5 flex-wrap">
<Tooltip content="Steam32位id" showArrow={true} delay={300}>
<span
className="text-xs cursor-pointer text-zinc-500 dark:text-zinc-400 hover:underline"
onClick={() => handleCopy(user.steam_id32.toString(), "Steam32位ID")}
>
{user.steam_id32}
</span>
</Tooltip>
<Tooltip content="Steam64位id" showArrow={true} delay={300}>
<span
className="text-xs cursor-pointer text-zinc-500 dark:text-zinc-400 hover:underline"
onClick={() => handleCopy(user.steam_id64.toString(), "Steam64位ID")}
>
{user.steam_id64.toString()}
</span>
</Tooltip>
</div>
<div className="flex gap-1.5 mt-auto">
<Button
size="sm"
radius="full"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(
steam.state.steamDir,
"userdata",
user.steam_id32.toString(),
"730",
"local",
"cfg"
),
})
addToast({ title: "个人CFG" })
}}
className="flex h-7"
>
CFG
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.switchLoginUser(id)}
className="flex h-7"
isDisabled={isMock}
>
</Button>
<Button
size="sm"
radius="full"
onPress={() => steam.selectUser(id)}
className="flex h-7"
isDisabled={isMock}
>
<Check size={10} />
</Button>
</div>
</li>
)
// 根据视图模式渲染用户项
const renderUserItem = (user: SteamUser, id: number) => {
const isMock = !!mockUsers
const realIndex = mockUsers
? -1
: steam.state.users.findIndex((u) => u.account_name === user.account_name)
const index = realIndex >= 0 ? realIndex : id
switch (viewMode) {
case "card":
return renderCardItem(user, index, isMock)
case "list":
return renderListItem(user, index, isMock)
case "list-large":
default:
return renderListLargeItem(user, index, isMock)
}
}
return (
<Card className="overflow-hidden">
<Card className="flex flex-col h-full overflow-hidden">
<CardHeader>
<CardIcon>
<User /> Steam用户
</CardIcon>
<CardTool>
<Tooltip content="切换样式" showArrow={true} delay={300}>
<Tabs
selectedKey={viewMode}
onSelectionChange={(key) => app.setSteamUsersViewMode(key as ViewMode)}
size="sm"
radius="full"
classNames={{
base: "min-w-0",
tabList: "gap-0 p-0",
tab: "min-w-0 px-2",
tabContent: "text-xs",
}}
>
<Tab key="card" title={<GridFour size={14} />} />
<Tab key="list" title={<List size={14} />} />
<Tab key="list-large" title={<List size={16} />} />
</Tabs>
</Tooltip>
{/* <ToolButton onClick={handleMockToggle}>{mockUsers ? "真实" : "模拟"}</ToolButton> */}
<ToolButton onClick={() => getUsers(true)}>
<Refresh />
</ToolButton>
</CardTool>
</CardHeader>
<CardBody className="overflow-y-hidden">
<ul
className="flex flex-col h-full gap-3 mt-1 overflow-y-auto rounded-lg pb-11 hide-scrollbar"
ref={parent}
>
{steam.state.users.map((user, id) => (
<li
key={user.account_name}
className="flex gap-2 transition border rounded-lg bg-white/70 border-zinc-200/50 dark:border-white/5 dark:bg-zinc-900/50"
>
<img
src={user.avatar ? `data:image/png;base64,${user.avatar}` : "/logo_square.png"}
alt="avatar"
className="w-20 h-20 rounded-l-lg"
draggable="false"
/>
<div className="flex flex-col flex-grow justify-center gap-2 p-0.5">
<h3 className="text-xl font-semibold">{user.persona_name}</h3>
<div className="flex gap-2">
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.account_name}
</Chip>
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.steam_id32}
</Chip>
<Chip size="sm" className="bg-zinc-200 dark:bg-zinc-800">
{user.steam_id64.toString()}
</Chip>
{user.recent > 0 && (
<Chip size="sm" color="primary">
</Chip>
)}
</div>
</div>
<div className="flex items-end gap-2 p-2">
<Button
size="sm"
onPress={async () => {
if (!steam.state.steamDirValid) {
addToast({ title: "Steam路径不可用", color: "warning" })
return
}
await invoke("open_path", {
path: path.resolve(steam.state.steamDir, "userdata", user.steam_id32.toString(), "730", "local", "cfg"),
})
addToast({ title: "个人CFG" })
}} className="gap-1"
>
CFG
</Button>
<Button size="sm" onPress={() => steam.switchLoginUser(id)} className="gap-1">
</Button>
<Button size="sm" onPress={() => steam.selectUser(id)} className="gap-1">
<Check size={14} />
</Button>
</div>
</li>
))}
</ul>
<CardBody className="flex-1 min-h-0 overflow-hidden">
{viewMode === "card" ? (
<ul
className="grid grid-cols-2 gap-3 mt-1 overflow-y-auto rounded-lg auto-rows-max md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 pb-11 hide-scrollbar"
ref={parent}
>
{displayUsers.map((user, id) => renderUserItem(user, id))}
</ul>
) : (
<ul
className="flex flex-col h-full gap-3 mt-1 overflow-y-auto rounded-lg pb-11 hide-scrollbar"
ref={parent}
>
{displayUsers.map((user, id) => renderUserItem(user, id))}
</ul>
)}
</CardBody>
</Card>
)

View File

@@ -13,6 +13,7 @@ const defaultValue = {
autoStart: false,
startHidden: false,
hiddenOnClose: false,
steamUsersViewMode: "list-large" as "card" | "list" | "list-large",
}
export const appStore = store("app", { ...defaultValue }, DEFAULT_STORE_CONFIG)
@@ -32,6 +33,7 @@ export const useAppStore = () => {
setAutoStart,
setStartHidden,
setHiddenOnClose,
setSteamUsersViewMode,
resetAppStore,
}
}
@@ -75,6 +77,10 @@ const setHiddenOnClose = (hiddenOnClose: boolean) => {
appStore.state.hiddenOnClose = hiddenOnClose;
}
const setSteamUsersViewMode = (viewMode: "card" | "list" | "list-large") => {
appStore.state.steamUsersViewMode = viewMode
}
const resetAppStore = () => {
setVersion(defaultValue.version)
setHasUpdate(defaultValue.hasUpdate)
@@ -84,4 +90,5 @@ const resetAppStore = () => {
setAutoStart(defaultValue.autoStart)
void setStartHidden(defaultValue.startHidden)
setHiddenOnClose(defaultValue.hiddenOnClose)
setSteamUsersViewMode(defaultValue.steamUsersViewMode)
}