diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5814730..9e09276 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { Form, Input, InputNumber, + Modal, Select, Space, Statistic, @@ -21,11 +22,15 @@ import { ClipboardList, ClipboardPaste, ListChecks, + Pencil, Play, + Plus, RefreshCw, Save, ShoppingCart, Square, + Settings, + Trash2, } from 'lucide-react' import { DefaultConfig, @@ -42,6 +47,8 @@ import { import { ClipboardGetText, EventsOn } from '../wailsjs/runtime/runtime' const storageKey = 'huya-go.gui.config.v2' +const cookieStorageKey = 'huya-go.gui.cookies.v1' +const prizeCacheStorageKey = 'huya-go.gui.prizes.v1' type UiConfig = { transport: string @@ -76,6 +83,15 @@ type AccountPreview = { error?: string } +type CookieEntry = { + id: string + text: string + uid: number + valid: boolean + error?: string + updatedAt: number +} + type LogEntry = { time: string level: string @@ -95,6 +111,12 @@ type PrizeInfo = { img?: string } +type PrizeCache = { + prizes: PrizeInfo[] + updatedAt: number + sid?: number +} + type ScoreResult = { uid: number score: number @@ -227,6 +249,55 @@ function readSavedConfig(): Partial { } } +function readSavedCookies(): CookieEntry[] { + try { + const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial[] + return items + .map((item, index) => ({ + id: item.id || `ck-${Date.now()}-${index}`, + text: String(item.text || '').trim(), + uid: Number(item.uid || 0), + valid: Boolean(item.valid), + error: item.error, + updatedAt: Number(item.updatedAt || Date.now()), + })) + .filter((item) => item.text) + } catch { + return [] + } +} + +function writeSavedCookies(items: CookieEntry[]) { + localStorage.setItem(cookieStorageKey, JSON.stringify(items)) +} + +function readSavedPrizeCache(): PrizeCache { + try { + const cache = JSON.parse(localStorage.getItem(prizeCacheStorageKey) || '{}') as Partial + const prizes = Array.isArray(cache.prizes) + ? cache.prizes.filter((item) => Number(item?.id || 0) > 0 && String(item?.name || '').trim()) + : [] + return { + prizes: prizes as PrizeInfo[], + updatedAt: Number(cache.updatedAt || 0), + sid: Number(cache.sid || 0) || undefined, + } + } catch { + return { prizes: [], updatedAt: 0 } + } +} + +function writeSavedPrizeCache(prizes: PrizeInfo[], sid: number) { + localStorage.setItem( + prizeCacheStorageKey, + JSON.stringify({ + prizes, + sid, + updatedAt: Date.now(), + }), + ) +} + function normalizeCookieText(text: string): string { const normalized = text .replace(/\r\n?/g, '\n') @@ -244,9 +315,20 @@ function normalizeCookieText(text: string): string { .join('\n') } +function cookieTextFromEntries(items: CookieEntry[]): string { + return items + .map((item) => item.text.trim()) + .filter(Boolean) + .join('\n') +} + +function newCookieID(): string { + return `ck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + function pickConfig(values: Partial): UiConfig { return { - transport: values.transport || 'http', + transport: values.transport || 'wss', wssHost: values.wssHost || '', sid: Number(values.sid || 0), pid: Number(values.pid || 0), @@ -309,9 +391,10 @@ function normalizeMonitorResult(row: MonitorResult): MonitorResult { } export default function Dashboard() { - const { message } = AntApp.useApp() + const { message, modal } = AntApp.useApp() const [form] = Form.useForm() const previewedCookieText = useRef('') + const watchedTransport = Form.useWatch('transport', form) const watchedPID = Form.useWatch('pid', form) const watchedName = Form.useWatch('name', form) const watchedCookieText = Form.useWatch('cookieText', form) @@ -327,19 +410,75 @@ export default function Dashboard() { const [results, setResults] = useState([]) const [logs, setLogs] = useState([]) const [now, setNow] = useState(new Date()) + const [settingsOpen, setSettingsOpen] = useState(false) + const [cookieManagerOpen, setCookieManagerOpen] = useState(false) + const [cookieEditOpen, setCookieEditOpen] = useState(false) + const [editingCookie, setEditingCookie] = useState(null) + const [cookieDraft, setCookieDraft] = useState('') + const [cookieEntries, setCookieEntries] = useState([]) + const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0) + + const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => { + const normalizedEntries = entries + .map((entry) => ({ + ...entry, + text: normalizeCookieText(entry.text).split('\n')[0] || '', + })) + .filter((entry) => entry.text) + + const cookieText = cookieTextFromEntries(normalizedEntries) + previewedCookieText.current = cookieText + form.setFieldsValue({ cookieText }) + setSelectedPrizeKeys([]) + + if (!cookieText) { + setCookieEntries([]) + setAccounts([]) + setSelectedAccountLines([]) + if (persist) writeSavedCookies([]) + return [] + } + + const previews = (await PreviewAccounts(cookieText)) as AccountPreview[] + const enriched = normalizedEntries.map((entry, index) => { + const preview = previews.find((item) => item.line === index + 1) + return { + ...entry, + uid: preview?.uid || 0, + valid: Boolean(preview?.valid), + error: preview?.error, + } + }) + setCookieEntries(enriched) + setAccounts(previews || []) + setSelectedAccountLines((previews || []).filter((item) => item.valid).map((item) => item.line)) + if (persist) writeSavedCookies(enriched) + if (refreshLatest && enriched.some((item) => item.valid)) { + window.setTimeout(() => queryPrizes(true), 0) + } + return enriched + } useEffect(() => { let mounted = true Promise.all([DefaultConfig(), GetLogs(), IsRunning()]) .then(([defaults, logItems, isRunning]) => { if (!mounted) return + const savedCookies = readSavedCookies() + const savedPrizeCache = readSavedPrizeCache() form.setFieldsValue({ ...(defaults as UiConfig), ...readSavedConfig(), - cookieText: '', + cookieText: cookieTextFromEntries(savedCookies), }) + setCookieEntries(savedCookies) + setPrizes(savedPrizeCache.prizes) + setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt) setLogs((logItems || []) as LogEntry[]) setRunning(Boolean(isRunning)) + if (savedCookies.length > 0) { + applyCookieEntries(savedCookies, true, true).catch((err: unknown) => message.error(String(err))) + } }) .catch((err: unknown) => message.error(String(err))) @@ -371,11 +510,14 @@ export default function Dashboard() { if ((watchedCookieText || '') === previewedCookieText.current) return setAccounts([]) setSelectedAccountLines([]) - setPrizes([]) setSelectedPrizeKeys([]) }, [watchedCookieText]) const validAccounts = useMemo(() => accounts.filter((item) => item.valid), [accounts]) + const importedCookieCount = useMemo( + () => normalizeCookieText(watchedCookieText || '').split('\n').filter(Boolean).length, + [watchedCookieText], + ) const selectedAccountCount = selectedAccountLines.length const selectedPrizeID = selectedPrizeKeys.length === 1 ? Number(selectedPrizeKeys[0]) || undefined : undefined @@ -425,15 +567,6 @@ export default function Dashboard() { ) }, [prizes, productSearch]) - const prizeOptions = useMemo( - () => - prizes.map((item) => ({ - value: item.id, - label: `${item.name} · ${item.id} · ${priceOf(item)}积分`, - })), - [prizes], - ) - const config = () => ({ ...pickConfig(form.getFieldsValue(true)), selectedAccountLines: selectedAccountLines.map(Number).filter((line) => line > 0), @@ -456,12 +589,43 @@ export default function Dashboard() { } } - const previewAccounts = async (cookieText?: string) => { - const text = cookieText ?? form.getFieldValue('cookieText') ?? '' - previewedCookieText.current = text - const items = (await PreviewAccounts(text)) as AccountPreview[] - setAccounts(items || []) - setSelectedAccountLines((items || []).filter((item) => item.valid).map((item) => item.line)) + const upsertCookieLines = async (lines: string[]) => { + const normalizedText = normalizeCookieText(lines.join('\n')) + const nextLines = normalizedText.split('\n').filter(Boolean) + if (nextLines.length === 0) return { added: 0, updated: 0 } + + const previews = (await PreviewAccounts(nextLines.join('\n'))) as AccountPreview[] + const nextEntries = [...cookieEntries] + let added = 0 + let updated = 0 + + nextLines.forEach((text, index) => { + const preview = previews.find((item) => item.line === index + 1) + const uid = preview?.uid || 0 + const valid = Boolean(preview?.valid) + const existingIndex = + valid && uid > 0 + ? nextEntries.findIndex((item) => item.valid && item.uid === uid) + : nextEntries.findIndex((item) => item.text === text) + const entry: CookieEntry = { + id: existingIndex >= 0 ? nextEntries[existingIndex].id : newCookieID(), + text, + uid, + valid, + error: preview?.error, + updatedAt: Date.now(), + } + if (existingIndex >= 0) { + nextEntries[existingIndex] = entry + updated += 1 + } else { + nextEntries.push(entry) + added += 1 + } + }) + + await applyCookieEntries(nextEntries, true, true) + return { added, updated } } const importCookiesFromClipboard = async () => { @@ -473,9 +637,8 @@ export default function Dashboard() { message.warning('剪贴板没有可导入的 CK') return } - form.setFieldsValue({ cookieText }) - await previewAccounts(cookieText) - message.success(`已从剪贴板导入 ${cookieText.split('\n').length} 条 CK`) + const result = await upsertCookieLines(cookieText.split('\n')) + message.success(`已导入 ${result.added} 条,更新 ${result.updated} 条 CK`) } catch (err: unknown) { message.error(`剪贴板导入失败: ${String(err)}`) } finally { @@ -483,20 +646,120 @@ export default function Dashboard() { } } - const queryPrizes = () => { - if (!String(form.getFieldValue('cookieText') || '').trim()) { - message.warning('Cookie 不能为空') + const openNewCookie = () => { + setEditingCookie(null) + setCookieDraft('') + setCookieEditOpen(true) + } + + const openEditCookie = (entry: CookieEntry) => { + setEditingCookie(entry) + setCookieDraft(entry.text) + setCookieEditOpen(true) + } + + const saveCookieDraft = async () => { + const normalizedText = normalizeCookieText(cookieDraft) + if (!normalizedText) { + message.warning('CK 不能为空') return } - runAction('list', () => ListPrizes(config()), (data) => { - const result = data as { prizes?: PrizeInfo[] } - const nextPrizes = result?.prizes || [] - setPrizes(nextPrizes) - setSelectedPrizeKeys((prev) => - prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))), - ) - if (nextPrizes.length === 0) { - message.warning('当前 CK 未返回可选商品') + setLoading('cookie-save') + try { + if (editingCookie) { + const text = normalizedText.split('\n')[0] + const nextEntries = cookieEntries.map((entry) => + entry.id === editingCookie.id ? { ...entry, text, updatedAt: Date.now() } : entry, + ) + await applyCookieEntries(nextEntries, true, true) + message.success('CK 已更新') + } else { + const result = await upsertCookieLines(normalizedText.split('\n')) + message.success(`已新增 ${result.added} 条,更新 ${result.updated} 条 CK`) + } + setCookieEditOpen(false) + setCookieDraft('') + setEditingCookie(null) + } catch (err: unknown) { + message.error(String(err)) + } finally { + setLoading('') + } + } + + const deleteCookie = async (entry: CookieEntry) => { + await applyCookieEntries(cookieEntries.filter((item) => item.id !== entry.id)) + message.success('CK 已删除') + } + + const clearCookies = () => { + modal.confirm({ + title: '清空 CK', + content: '确认删除所有已保存 CK?', + okText: '清空', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + await applyCookieEntries([]) + message.success('CK 已清空') + }, + }) + } + + const reloadSavedCookies = async () => { + const savedCookies = readSavedCookies() + await applyCookieEntries(savedCookies, true, true) + message.success('已重新载入本地 CK') + } + + function queryPrizes(quiet = false) { + if (!String(form.getFieldValue('cookieText') || '').trim()) { + if (!quiet) message.warning('Cookie 不能为空') + return + } + setLoading('list') + ListPrizes(config()) + .then((data) => { + const result = data as { prizes?: PrizeInfo[] } + const nextPrizes = result?.prizes || [] + if (nextPrizes.length === 0) { + if (!quiet) message.warning('当前 CK 未返回可选商品') + return + } + const updatedAt = Date.now() + setPrizes(nextPrizes) + setPrizeCacheUpdatedAt(updatedAt) + writeSavedPrizeCache(nextPrizes, Number(form.getFieldValue('sid') || 0)) + setSelectedPrizeKeys((prev) => + prev.filter((key) => nextPrizes.some((item) => item.id === Number(key))), + ) + if (!quiet) message.success('商品已刷新') + }) + .catch((err: unknown) => { + if (!quiet) message.error(String(err)) + }) + .finally(() => { + setLoading('') + }) + } + + const productListUpdatedAt = prizeCacheUpdatedAt + ? new Date(prizeCacheUpdatedAt).toLocaleString('zh-CN', { hour12: false }) + : '' + + const clearPrizeCache = () => { + modal.confirm({ + title: '清空商品缓存', + content: '确认清空本地缓存的商品列表?', + okText: '清空', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: () => { + localStorage.removeItem(prizeCacheStorageKey) + setPrizes([]) + setSelectedPrizeKeys([]) + setPrizeCacheUpdatedAt(0) + message.success('商品缓存已清空') } }) } @@ -546,8 +809,6 @@ export default function Dashboard() { }) } - const choosePrize = (pid?: number) => applyPrizeSelection(pid ? [pid] : []) - const startMonitor = (scheduled: boolean) => { const values = config() if (!scheduled) values.startAt = '' @@ -621,6 +882,51 @@ export default function Dashboard() { [currentPrize?.name, latestResultByUID, scoreMap, watchedName], ) + const cookieColumns: ColumnsType = useMemo( + () => [ + { + title: '账号', + width: 150, + render: (_, row) => ( +
+ {row.uid || '-'} +
{row.valid ? '有效 CK' : row.error || '待校验'}
+
+ ), + }, + { + title: '状态', + width: 86, + render: (_, row) => (row.valid ? 有效 : 无效), + }, + { + title: 'CK', + ellipsis: true, + render: (_, row) => {row.text}, + }, + { + title: '更新时间', + width: 170, + render: (_, row) => new Date(row.updatedAt).toLocaleString('zh-CN', { hour12: false }), + }, + { + title: '操作', + width: 150, + render: (_, row) => ( + + + + + ), + }, + ], + [cookieEntries], + ) + const prizeColumns: ColumnsType = useMemo( () => [ { title: 'num', dataIndex: 'id', width: 92 }, @@ -759,7 +1065,8 @@ export default function Dashboard() { block icon={} loading={loading === 'list'} - onClick={queryPrizes} + disabled={validAccounts.length === 0} + onClick={() => queryPrizes()} > 用 CK 刷新商品 @@ -775,15 +1082,15 @@ export default function Dashboard() {
- 实时库存 {filteredPrizes.length} 个 - {now.toLocaleString('zh-CN', { hour12: false })} + 商品缓存 {filteredPrizes.length} 个 + {productListUpdatedAt ? `更新 ${productListUpdatedAt}` : '未缓存'}
{filteredPrizes.length === 0 ? (
- 未加载 CK 商品 - 列表为空 + 暂无商品缓存 + 导入有效 CK 后刷新最新商品
) : ( filteredPrizes.map((item) => ( @@ -817,19 +1124,14 @@ export default function Dashboard() {
- +
- - - + + + + setCookieManagerOpen(false)} + footer={null} + > +
+
+ + + + + + + {cookieEntries.length} 条 CK + {validAccounts.length} 个有效 + + +
+ row.id} + columns={cookieColumns} + dataSource={cookieEntries} + pagination={{ pageSize: 8, showSizeChanger: false }} + scroll={{ x: 900, y: 430 }} + /> + + + { + setCookieEditOpen(false) + setCookieDraft('') + setEditingCookie(null) + }} + > + setCookieDraft(event.target.value)} + /> + + setSettingsOpen(false)} + footer={[ + , + , + ]} + > +
+
+ + +
+ CK 来源 + + {importedCookieCount > 0 + ? `已导入 ${validAccounts.length}/${importedCookieCount} 个有效账号` + : '未导入 CK'} +
+
+ 商品缓存 + + + {prizes.length > 0 + ? `${prizes.length} 个 · ${productListUpdatedAt || '未知时间'}` + : '未缓存'} + + + +
+
+
@@ -998,44 +1423,29 @@ export default function Dashboard() { - - -
- - +
+
+ + - - + + - - + + - - + + -
- - - - - - - - - - - - - - - - - - -
- - -
+ + + + + + +
+ +
) } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ceb9145..fb0e509 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -165,7 +165,7 @@ body { display: grid; min-width: 0; min-height: 0; - grid-template-rows: 64px 48px 72px minmax(0, 1fr) auto auto; + grid-template-rows: 64px 48px 72px minmax(0, 1fr); background: #f7fafc; } @@ -184,10 +184,6 @@ body { gap: 12px; } -.transport-select { - width: 96px; -} - .statusbar { display: flex; align-items: center; @@ -329,9 +325,7 @@ body { padding: 8px 2px; } -.stats-pane .ant-statistic, -.bottom-config, -.advanced-config { +.stats-pane .ant-statistic { border: 1px solid #e6edf3; border-radius: 8px; background: #fff; @@ -341,43 +335,87 @@ body { padding: 14px; } -.bottom-config { +.settings-modal { display: grid; - grid-template-columns: minmax(280px, 1fr) repeat(6, max-content); - gap: 10px; - align-items: end; - margin: 0 20px 10px; - padding: 10px 12px; + gap: 16px; + padding-top: 4px; } -.bottom-config .ant-form-item, -.advanced-config .ant-form-item { +.ck-manager { + display: grid; + gap: 12px; +} + +.ck-manager-tools { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +.ck-text-preview { + display: block; + overflow: hidden; + color: #344054; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.settings-wide { + grid-column: 1 / -1; +} + +.settings-summary { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 38px; + border: 1px solid #e6edf3; + border-radius: 6px; + padding: 8px 10px; +} + +.settings-grid .ant-form-item, +.settings-param-grid .ant-form-item, +.settings-switch-grid .ant-form-item { margin-bottom: 0; } -.target-select { - width: 100%; - margin-top: 6px; -} - -.advanced-config { - display: grid; - grid-template-columns: 180px minmax(220px, 0.8fr) minmax(220px, 1fr) minmax(320px, 1.2fr); - gap: 10px; - align-items: end; - margin: 0 20px 14px; - padding: 10px 12px; -} - -.switch-grid { +.settings-param-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; + gap: 10px; } -.cookie-box textarea, -.cookie-box { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +.settings-param-grid .ant-input-number { + width: 100%; +} + +.settings-switch-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.settings-switch-grid .ant-form-item { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 38px; + border: 1px solid #e6edf3; + border-radius: 6px; + padding: 8px 10px; +} + +.settings-switch-grid .ant-form-item-row { + width: 100%; } .ant-table-wrapper { @@ -400,9 +438,10 @@ body { grid-template-columns: 1fr; } - .bottom-config, - .advanced-config { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .settings-grid, + .settings-param-grid, + .settings-switch-grid { + grid-template-columns: 1fr; } } diff --git a/internal/gui/app.go b/internal/gui/app.go index 6cb75be..e8f8851 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -125,7 +125,7 @@ func (a *App) Shutdown(ctx context.Context) { func (a *App) DefaultConfig() Config { return Config{ - Transport: "http", + Transport: "wss", WSSHost: huya.DefaultWSSHost, SID: 2203, Loops: 20,