diff --git a/.gitignore b/.gitignore index b43362b..7fa3156 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,12 @@ dist-ssr *.sln *.sw? stats.html +.vite-dev*.log +.pnpm-store/ + +# 本地审计导出 +audit/* +!audit/backend-pr/ + +.commit-msg.txt +_tmp_autopcr_patchgen/ diff --git a/audit/backend-pr/README.md b/audit/backend-pr/README.md new file mode 100644 index 0000000..8f2f7e2 --- /dev/null +++ b/audit/backend-pr/README.md @@ -0,0 +1,50 @@ +# 半月刊字段化 — 后端 PR 素材(提交流程说明) + +## 背景 +Discord 讨论定案:半月刊(half_schedule, `autopcr/module/modules/nologin.py`)的解析逻辑 +挪进 `db`(database.py)返回结构化数据,供 module 渲染与网站 API 共用;网页端基于此做 +「复刻活动/特别地下城/新斗技场/公会战开启」通知,不再依赖运行任务读警报。 + +## 重要约束(为什么这个目录里只有素材没有直接改动) +后端仓库 U:\autopcr(cc004/autopcr,dev 分支)正在被本机 Docker 容器运行,工作区不能直接改。 +本目录保存已完成的设计与代码素材,PR 需要在后端仓库的独立分支上落地。 + +## 提 PR 步骤(在后端仓库做) +1. cd U:\autopcr +2. git checkout -b feat/schedule-entries origin/dev +3. 按本目录两个 .py 素材文件落改动: + - database_schedule_entries.py 的方法体 → 粘进 autopcr/db/database.py 的 + database 类(放 is_clan_battle_time 之前即可;List/Counter 已在文件头有 import) + - nologin_half_schedule.py 的 do_task → 替换 autopcr/module/modules/nologin.py + half_schedule 类里现有的 do_task(schedule_sources 静态方法可保留,已无消费者,也可一并删) +4. 本地验证: + - python -c "from autopcr.db.dbstart import db_start; db_start(); from autopcr.db.database import db; e=db.schedule_entries(); print(len(e), e[0])" + (必须先 db_start():lazy_property 在 dbmgr 未初始化时直接抛 ValueError;db_start 会连带拉起登录依赖,属预期) + - 跑一次半月刊 module,输出应与改动前逐行一致(排序键 (start, end, 源清单顺序),组内顺序=源清单顺序) +5. httpserver.py 加只读端点(不挂 login_required 装饰器即为公开,与 /validate 同风格; + 注意 /clan_forbid 其实带 login+admin,别学它): + @self.api.route('/schedule', methods=["GET"]) + async def get_schedule(): + return db.schedule_entries(), 200 + (注意 schedule_entries 每次调用都全表扫 17 张红标表,量级小可接受;若在意可加 lazy_property 缓存, + 但 enabled 依赖当前时间,缓存须带 TTL——首版建议不加) +6. git add autopcr/db/database.py autopcr/module/modules/nologin.py autopcr/http_server/httpserver.py + git commit;push 到自己 fork 或分支;向 cc004/autopcr 提 PR。 + +## 字段化数据形状 +每条日程:{ key, category, start_time, end_time, description } +- key:稳定唯一("类别:来源主键",同主键多来源加 #序号),通知侧已读去重直接用它 +- category:半月刊渲染的类别名(公会战/特别地下城/新斗技场/庆典/活动/扭蛋/...) +- start_time / end_time:YYYY/MM/DD(与半月刊渲染一致) +- description:与半月刊逐行渲染完全相同的文本(up 名单、掉落倍率等都在这里面); + 扭蛋条目若 gacha_name 含 フェス/FES,前缀加 fes|(前端折叠为「up 首人 fes扭蛋」) + +## 前端侧状态(已在 AutoPCR_Web 实现,等后端 API 合入即切换) +- GET /daily/api/schedule → ScheduleEntry[](vite proxy 指向真后端即自动生效,前端零改动) +- ScheduleNotifyWatcher(挂 _sidebar)+ 面板(DashBoard 工具栏): + - 展示口径:end_time > 今天 的条目;噪声过滤(玩家经验值加成、公会战排名公示整类) + - 类别:固定清单 13 类(活动/女神祭/庆典/扭蛋/免费十连/公会战/特别地下城/新斗技场/季卡驾车游/露娜塔/次元断层/深渊讨伐战/赛马); + 驾车游并入季卡、斗技场/登录奖励前端不显示;女神祭由 description 含「女神祭」从活动拆出 + - 扭蛋折叠:description 带 fes| 前缀(= gacha_name 含 フェス/FES,由后端 schedule_entries 织入)→「up 首人 fes扭蛋」; + 普通长名单 →「up 前两名……等N人」 +- 通知:勾选类别条目 start_time == 今天 且到设定时刻 → 浏览器通知(每 key 当天一次,localStorage 已读 90 天自清理) diff --git a/audit/backend-pr/database_schedule_entries.py b/audit/backend-pr/database_schedule_entries.py new file mode 100644 index 0000000..203590c --- /dev/null +++ b/audit/backend-pr/database_schedule_entries.py @@ -0,0 +1,69 @@ + def schedule_entries(self) -> List[dict]: + """字段化全部日程(纯静态红标数据,不依赖账号/登录)。半月刊 module 与网站 API 共用此单一来源。 + 每条:{key, category, start_time, end_time, description} + - key: 稳定唯一标识("类别:行主键",hatsune+seven 同 event_id 时加 #序号),通知侧用它做已读去重 + - category: 语义类别(公会战/特别地下城/庆典/活动/...),调用方按需过滤 + - description: 与半月刊渲染文本一致的展示文案;扭蛋 fes 池带 fes| 前缀(渲染侧需剥离,见 half_schedule.do_task) + """ + from ..module.modules.nologin import ( + ClanBattlePeriod, SecretDungeonSchedule, GachaDatum, CampaignSchedule, + CampaignFreegacha, HatsuneSchedule, TowerSchedule, TdfSchedule, + ColosseumScheduleData, DomeScheduleData, AbyssSchedule, CaravanSchedule, + CharaFortuneSchedule, LoginBonusDatum, SeasonpassFoundation, + ) + + # (来源表, 工厂);与半月刊原 schedule_sources 清单一致(顺序即渲染组内顺序) + sources = [ + (self.clan_battle_period, lambda x: ClanBattlePeriod(x.start_time, x.end_time, "公会战")), + (self.clan_battle_period, lambda x: ClanBattlePeriod(x.result_start, x.result_end, "公会战排名公示")), + (self.secret_dungeon_schedule, lambda x: SecretDungeonSchedule(x.start_time, x.end_time, "特别地下城")), + (self.seasonpass_foundation, lambda x: SeasonpassFoundation(x.name, x.start_time, x.end_time, "季卡")), + (self.gacha_data, lambda x: GachaDatum(x.gacha_id, x.exchange_id, x.start_time, x.end_time, "扭蛋")), + (self.campaign_schedule, lambda x: CampaignSchedule(x.id, x.campaign_category, x.value, x.start_time, x.end_time, "庆典")), + (self.campaign_free_gacha, lambda x: CampaignFreegacha(x.campaign_id, x.start_time, x.end_time, "免费十连")), + (self.hatsune_schedule, lambda x: HatsuneSchedule(x.event_id, x.start_time, x.end_time, "活动")), + (self.seven_schedule, lambda x: HatsuneSchedule(x.event_id, x.start_time, x.end_time, "活动")), + (self.tower_schedule, lambda x: TowerSchedule(x.start_time, x.end_time, "露娜塔")), + (self.tdf_schedule, lambda x: TdfSchedule(x.start_time, x.end_time, "次元断层")), + (self.chara_fortune_schedule, lambda x: CharaFortuneSchedule(x.name, x.start_time, x.end_time, "赛马")), + (self.login_bonus_data, lambda x: LoginBonusDatum(x.name, x.start_time, x.end_time, "登录奖励")), + (self.colosseum_schedule_data, lambda x: ColosseumScheduleData(x.start_time, x.end_time, "斗技场")), + (self.caravan_schedule, lambda x: CaravanSchedule(x.season_id, x.start_time, x.end_time, "驾车游")), + (self.dome_schedule_data, lambda x: DomeScheduleData(x.start_time, x.end_time, "新斗技场")), + (self.abyss_schedule, lambda x: AbyssSchedule(x.talent_id, x.start_time, x.end_time, "深渊讨伐战")), + ] + + entries: List[dict] = [] + counters: Counter = Counter() + order = 0 + for table, factory in sources: + # key 生成必须在 enabled 过滤之前:行从 enabled 转 disabled 时,后续行 #n 不得漂移 + for row_key, row in table.items(): + schedule = factory(row) + # key = 语义类别 + 行主键(dict 键即行主键,不取 name 等文本列——防改名/含分隔符导致 key 漂移) + base_key = ':'.join([schedule.description, str(row_key)]) + counters[base_key] += 1 + stable_key = base_key if counters[base_key] == 1 else base_key + '#' + str(counters[base_key]) + if not schedule.enabled: + continue + desc = schedule.get_description() + # 扭蛋条目:官方池名 gacha_name 含 フェス/FES 判定为 fes 池,织入标记供通知侧折叠(只留第一人 + fes扭蛋); + # 渲染侧(half_schedule.do_task)负责剥离前缀,保证半月刊输出 parity + if schedule.description == "扭蛋": + gacha_name = row.gacha_name or '' + if ('フェス' in gacha_name) or ('FES' in gacha_name.upper()): # 宽松子串匹配是有意的 + desc = 'fes|' + desc + entries.append({ + 'key': stable_key, + 'category': schedule.description, + 'start_time': self.format_date(self.parse_time(schedule.start_time)), + 'end_time': self.format_date(self.parse_time(schedule.end_time)), + 'description': desc, + '_order': order, + }) + order += 1 + # 排序与半月刊旧实现一致:(start, end, 源清单顺序)——不得用 key 码点序,否则组内顺序漂移破坏 parity + entries.sort(key=lambda e: (e['start_time'], e['end_time'], e['_order'])) + for e in entries: + e.pop('_order') + return entries diff --git a/audit/backend-pr/httpserver_schedule_route.py b/audit/backend-pr/httpserver_schedule_route.py new file mode 100644 index 0000000..ab74ed2 --- /dev/null +++ b/audit/backend-pr/httpserver_schedule_route.py @@ -0,0 +1,10 @@ +# autopcr/http_server/httpserver.py — 两处改动: +# ① 文件顶部 import 区(accountmgr import 之后)补(全文件当前没有 db 导入,不加必 NameError): +# from ..db.database import db +# ② configure_routes 内(put_clan_forbid PUT 块之后)加路由。 +# 公开只读:不挂 login_required 即为公开(先例 /validate,httpserver.py:501); +# 别学 /clan_forbid——它挂了 login+admin,不是公开路由。 +@self.api.route('/schedule', methods=["GET"]) +async def get_schedule(): + """半月刊结构化日程(字段化,无账号依赖)。网页端通知与本 module 渲染共用数据源。""" + return db.schedule_entries(), 200 diff --git a/audit/backend-pr/nologin_half_schedule.py b/audit/backend-pr/nologin_half_schedule.py new file mode 100644 index 0000000..3ce7ddf --- /dev/null +++ b/audit/backend-pr/nologin_half_schedule.py @@ -0,0 +1,18 @@ + async def do_task(self, _: pcrclient): + # 结构化数据单一来源:db.schedule_entries()(字段化供 API/通知复用),这里只做渲染 + schedules = defaultdict(list) + for entry in db.schedule_entries(): + schedules[(entry['start_time'], entry['end_time'])].append(entry['description']) + times = sorted(schedules.keys()) + mirai = False + for time in times: + st = time[0] + ed = time[1] + if not mirai and db.parse_time(st) > datetime.now(): + mirai = True + self._log("\n====未来日程====") + self._log(f"{st} - {ed}") + for msg in schedules[time]: + if msg.startswith('fes|'): + msg = msg[4:] # 剥离 fes 标记(API 契约字段,半月刊渲染不带前缀,保持 parity) + self._log(f" {msg}") diff --git a/audit/backend-pr/schedule-entries.patch b/audit/backend-pr/schedule-entries.patch new file mode 100644 index 0000000..7f81691 Binary files /dev/null and b/audit/backend-pr/schedule-entries.patch differ diff --git a/index.html b/index.html index ef4f64a..d886b08 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,13 @@ AutoPCR +
diff --git a/scripts/apply-backend-pr.cjs b/scripts/apply-backend-pr.cjs new file mode 100644 index 0000000..7ebcbfc --- /dev/null +++ b/scripts/apply-backend-pr.cjs @@ -0,0 +1,84 @@ +const fs = require('fs'); +const root = 'U:/autopcr/web/autopcr-main'; +const mat = fs.readFileSync('U:/autopcr/web/AutoPCR_Web/audit/backend-pr/database_schedule_entries.py', 'utf8'); + +// 1) database.py +const dbp = root + '/autopcr/db/database.py'; +let db = fs.readFileSync(dbp, 'utf8'); +if (db.includes('def schedule_entries')) { + console.log('database.py: schedule_entries already present'); +} else { + const marker = ' def is_clan_battle_time(self, now: Union[None, datetime.datetime] = None) -> bool:'; + const i = db.indexOf(marker); + if (i === -1) throw new Error('is_clan_battle_time marker not found'); + db = db.slice(0, i) + mat.replace(/\n$/, '') + '\n\n' + db.slice(i); + fs.writeFileSync(dbp, db, 'utf8'); + console.log('database.py: schedule_entries inserted'); +} + +// 2) httpserver.py +const hpp = root + '/autopcr/http_server/httpserver.py'; +let hp = fs.readFileSync(hpp, 'utf8'); +if (!hp.includes('from ..db.database import db')) { + const m2 = hp.match(/(from \.\.constants import[^\n]*\n)/); + if (!m2) throw new Error('constants import anchor not found'); + hp = hp.replace(m2[1], m2[1] + 'from ..db.database import db\n'); + console.log('httpserver.py: db import added'); +} +if (!hp.includes("'/schedule'")) { + const pf = hp.indexOf('async def put_clan_forbid():'); + if (pf === -1) throw new Error('put_clan_forbid not found'); + const retMark = "return f'设置成功,禁止了{len(data)}个账号', 200"; + const ri = hp.indexOf(retMark, pf); + if (ri === -1) throw new Error('put_clan_forbid return not found'); + const insertAt = hp.indexOf('\n', ri) + 1; + const route = [ + '', + " @self.api.route('/schedule', methods = [\"GET\"])", + ' async def get_schedule():', + ' """半月刊结构化日程(字段化,无账号依赖)。网页端通知与本 module 渲染共用数据源。"""', + ' return db.schedule_entries(), 200', + ].join('\n') + '\n'; + hp = hp.slice(0, insertAt) + route + hp.slice(insertAt); + console.log('httpserver.py: /schedule route added'); +} +fs.writeFileSync(hpp, hp, 'utf8'); + +// 3) nologin.py do_task +const nlp = root + '/autopcr/module/modules/nologin.py'; +let nl = fs.readFileSync(nlp, 'utf8'); +if (nl.includes('db.schedule_entries()')) { + console.log('nologin.py: do_task already consumes schedule_entries'); +} else { + const start = nl.indexOf(' async def do_task(self, _: pcrclient):', nl.indexOf('class half_schedule')); + if (start === -1) throw new Error('half_schedule.do_task not found'); + const tailMark = 'self._log(f" {msg}")'; + const ti = nl.indexOf(tailMark, start); + if (ti === -1) throw new Error('do_task tail not found'); + const end = nl.indexOf('\n', ti) + 1; + const newDo = [ + ' async def do_task(self, _: pcrclient):', + ' # 结构化数据单一来源:db.schedule_entries()(字段化供 API/通知复用),这里只做渲染', + ' schedules = defaultdict(list)', + ' for entry in db.schedule_entries():', + " msg = entry['description']", + " if msg.startswith('fes|'):", + ' msg = msg[4:] # 剥离 fes 标记(通知侧契约),半月刊渲染保持 parity', + " schedules[(entry['start_time'], entry['end_time'])].append(msg)", + ' times = sorted(schedules.keys())', + ' mirai = False', + ' for time in times:', + ' st = time[0]', + ' ed = time[1]', + ' if not mirai and db.parse_time(st) > datetime.now():', + ' mirai = True', + ' self._log("\\n====未来日程====")', + ' self._log(f"{st} - {ed}")', + ' for msg in schedules[time]:', + ' self._log(f" {msg}")', + ].join('\n') + '\n'; + nl = nl.slice(0, start) + newDo + nl.slice(end); + fs.writeFileSync(nlp, nl, 'utf8'); + console.log('nologin.py: do_task replaced'); +} +console.log('ALL DONE'); diff --git a/scripts/mock-schedule-server.mjs b/scripts/mock-schedule-server.mjs new file mode 100644 index 0000000..b660051 --- /dev/null +++ b/scripts/mock-schedule-server.mjs @@ -0,0 +1,35 @@ +/** + * 半月刊 /schedule 联调 mock:数据逐条对照真实半月刊页面(2026/09/10 时点快照)。 + * 用法:node scripts/mock-schedule-server.mjs → 监听 13201;配合 AUTOPCR_SERVER_HOST=http://localhost:13201 起 vite。 + */ +import { createServer } from 'node:http'; + +const rows = [ + { key: '季卡:1', category: '季卡', start_time: '2026/07/01', end_time: '2026/09/15', description: '驾车游第9季' }, + { key: '扭蛋:2', category: '扭蛋', start_time: '2026/08/31', end_time: '2026/09/08', description: 'up 艾拉,紫罗兰,菲欧,格蕾丝,白菲,涅妃,银莲,els,琉璃,莱莱,机娘,坏女人,龙安,爱梅斯,咲哈哈,兰法,小小甜心,龙妈,星栞,风剑,白猫,火猫,晶,飞田,蝶妈,高达,似似花,611,克总' }, + { key: '活动:3', category: '活动', start_time: '2026/08/31', end_time: '2026/09/23', description: ', And I Will \n牢牢紧握在手中' }, + { key: '活动:4', category: '活动', start_time: '2026/08/31', end_time: '2026/10/30', description: '女神祭' }, + { key: '庆典:5', category: '庆典', start_time: '2026/09/01', end_time: '2026/09/23', description: '活动 玩家经验值*1.5' }, + { key: '庆典:6', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/15', description: '圣迹 掉落*2.0' }, + { key: '庆典:7', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/15', description: '神殿 掉落*2.0' }, + { key: '庆典:8', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/24', description: 'vh 掉落*2.0' }, + { key: '庆典:9', category: '庆典', start_time: '2026/09/07', end_time: '2026/09/24', description: 'vh mana*2.0' }, + { key: '公会战排名公示:10', category: '公会战排名公示', start_time: '2026/09/07', end_time: '2026/09/28', description: '公会战排名公示' }, + { key: '新斗技场:11', category: '新斗技场', start_time: '2026/09/08', end_time: '2026/09/12', description: '新斗技场' }, + { key: '扭蛋:12', category: '扭蛋', start_time: '2026/09/08', end_time: '2026/09/15', description: 'up 诗夏,紫罗兰,菲欧,格蕾丝,白菲,涅妃,银莲,els,琉璃,莱莱,机娘,坏女人,龙安,爱梅斯,咲哈哈,兰法,小小甜心,龙妈,星栞,风剑,白猫,火猫,晶,飞田,蝶妈,高达,似似花,611,克总' }, + { key: '庆典:13', category: '庆典', start_time: '2026/09/15', end_time: '2026/09/20', description: 'normal 掉落*3.0' }, + { key: '庆典:14', category: '庆典', start_time: '2026/09/15', end_time: '2026/09/29', description: '地下城 mana*2.0' }, + { key: '活动:15', category: '活动', start_time: '2026/09/16', end_time: '2026/09/23', description: '幻惑的妖精 \n‐盛夏乐园中舞动的身影‐' }, + { key: '公会战:16', category: '公会战', start_time: '2026/09/26', end_time: '2026/09/30', description: '公会战' }, + { key: '公会战排名公示:17', category: '公会战排名公示', start_time: '2026/10/07', end_time: '2026/10/28', description: '公会战排名公示' }, +]; +createServer((req, res) => { + const url = req.url ?? ''; + if (url.startsWith('/daily/api/schedule')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(rows)); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end('{}'); +}).listen(13201, () => console.log('mock schedule server on http://localhost:13201')); diff --git a/scripts/vite.dev-no-watch.config.mts b/scripts/vite.dev-no-watch.config.mts new file mode 100644 index 0000000..37ad96f --- /dev/null +++ b/scripts/vite.dev-no-watch.config.mts @@ -0,0 +1,39 @@ +// 联调专用:关闭 HMR 文件监听(Docker/索引器碰工作区文件会令 chokidar EBUSY 崩溃退出)。 +// 用法:node node_modules/vite/bin/vite.js --config scripts/vite.dev-no-watch.config.mts +// 常规开发请继续用默认 vite.config.ts。 +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; +import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; +import path from 'path'; +import { readFileSync } from 'fs'; + +const version = JSON.parse(readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8')).version; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + base: '/daily', + server: { + proxy: { + '/daily/api': { + target: env.AUTOPCR_SERVER_HOST || 'http://localhost:13200', + changeOrigin: true, + }, + }, + host: env.AUTOPCR_WEBUI_LISTEN || 'localhost', + watch: { ignored: ['**/autopcr/**', '**/audit/**', '**/scripts/**'] }, + hmr: false, + }, + resolve: { + alias: { + '@': path.resolve(process.cwd(), 'src'), + '@api': path.resolve(process.cwd(), 'src/api'), + '@components': path.resolve(process.cwd(), 'src/components'), + '@interfaces': path.resolve(process.cwd(), 'src/interfaces'), + '@routes': path.resolve(process.cwd(), 'src/routes'), + }, + }, + define: { APP_VERSION: JSON.stringify(version) }, + plugins: [react(), TanStackRouterVite()], + }; +}); diff --git a/src/components/Account/AccountCard.tsx b/src/components/Account/AccountCard.tsx new file mode 100644 index 0000000..aa9b572 --- /dev/null +++ b/src/components/Account/AccountCard.tsx @@ -0,0 +1,713 @@ +import { AccountInfo as AccountInfoInterface } from '@interfaces/UserInfo'; +import { Box, Card, Flex, HStack, Input, Table, Text, useDisclosure } from '@chakra-ui/react'; +import { FiActivity, FiCheck, FiCopy, FiTarget, FiUpload, FiUserX, FiX } from 'react-icons/fi'; +import React, { ChangeEvent, useRef } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { Route as DashBoardRoute } from '@routes/daily/_sidebar/account/index'; +import Alert from '../alert'; +import { AxiosError } from 'axios'; +import { IconButton } from '../../components/ui/icon-button'; +import { Tooltip } from '../../components/ui/tooltip'; +import NiceModal from '@ebay/nice-modal-react'; +import ResultInfoModal from './ResultInfoModal'; +import { toaster } from '../../components/ui/toaster'; +import { delAccount, getAccount, getAccountDailyResultList, postAccountAreaDaily } from '@api/Account'; +import { getErrorDescription } from './Config'; +import { dailyCleanRegistry as handle, DISPLAY_NAME_KEY, getDisplayName, emitDailyFinished, safeSetItem, safeRemoveItem, importConfigFile, busyAccountsRef } from './accountShared'; +import { clearAreaConfigCache } from './Area'; +import { RoundCheckbox, AccountTags, StatusTag } from './AccountCardParts'; +interface AccountInfoProps { + account: AccountInfoInterface; + onToggle: () => void; + updateAccountInfo: (updatedAccount: AccountInfoInterface) => void; + isTableView?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + /** 自动批次(多"默认账号")名单:成员卡片显示"默认"标 */ + batchAccounts?: string[]; + onOpenSyncConfig?: (alias: string) => void; + /** 该账号是否有动作正在执行(转圈=忙,其他动作不可对其生效) */ + isBusy?: boolean; + onBusyChange?: (alias: string, busy: boolean) => void; + getOccupiedNames: (selfAlias: string) => Set; +} + +export function AccountInfo({ + account, + onToggle, + updateAccountInfo, + isTableView = false, + isSelected = false, + onToggleSelect, + batchAccounts = [], + onOpenSyncConfig, + isBusy, + onBusyChange, + getOccupiedNames, +}: AccountInfoProps) { + const buttonLoading = useDisclosure(); + const alias = account.name; + const deleteConfirm = useDisclosure(); + const navigate = useNavigate(); + const importFileRef = useRef(null); + const cancelRef = React.useRef(null); + + const [isEditingName, setIsEditingName] = useState(false); + const [displayName, setDisplayName] = useState(() => getDisplayName(alias)); + const [nameDraft, setNameDraft] = useState(displayName); + const composingRef = useRef(false); + const commitNameRef = useRef<() => void>(() => {}); // setTimeout 兜底用:直接捕获 commitDisplayName 会拿到事件时刻旧闭包(nameDraft 旧值) + const nameInputRef = useRef(null); + // 批量清理的提示文案要在按下那一刻取最新显示名;登记只看账号原名 + const displayNameRef = useRef(displayName); + displayNameRef.current = displayName; + // 显示名重同步跳过位:存储写失败后保住本地新名(见 commitDisplayName) + const skipDisplayResyncRef = useRef(false); + // 忙碌真源 = busyAccountsRef(模块级,跨页存活);互斥判定一律 has(alias),父级登记经 onBusyRef。 + // 旧的 busyRef/localBusyRef 镜像已删:真源换读法后无读者,且与 busyAccountsRef 一词之差极易误读(审计十 #10) + const onBusyRef = useRef(onBusyChange); + onBusyRef.current = onBusyChange; + + const clean = account.daily_clean_time; + const cleanStatus = clean?.status || '未知'; + const cleanTime = clean?.time || ''; + + const statusMeta = + cleanStatus === '成功' || cleanStatus === '跳过' + ? { color: 'green' as const, icon: , label: cleanStatus === '跳过' ? '跳过' : '完成' } + : cleanStatus === '警告' || cleanStatus === '中止' + ? { color: 'orange' as const, icon: , label: cleanStatus } + : cleanStatus === '错误' + ? { color: 'red' as const, icon: , label: '错误' } + : { color: 'gray' as const, icon: , label: cleanStatus }; + + useEffect(() => { + // 重同步跳过位:存储写失败时本地已显示新名,不从存储读回旧值(否则闪一下又弹回,与注释意图相反) + if (skipDisplayResyncRef.current) { + skipDisplayResyncRef.current = false; + return; + } + const latest = getDisplayName(alias); + setDisplayName(latest); + if (!isEditingName) setNameDraft(latest); + }, [alias, isEditingName]); + + const handleCleanDaily = async () => { + // 守卫读真源(含详情页登记的忙碌):镜像 busyRef 只反映父级 state+本卡 local,跨页直写看不见 + if (busyAccountsRef.has(alias)) { + toaster.create({ type: 'warning', title: '该账号正在执行中,请等待执行完毕' }); + return; + } + buttonLoading.onOpen(); + onBusyRef.current?.(alias, true); // 本地动作登记真源:批量/同步/详情页全经 busyAccountsRef 互斥 + const nameForUi = displayNameRef.current || alias; + toaster.create({ type: 'info', title: `开始为${nameForUi}清理日常...` }); + try { + const res = await postAccountAreaDaily(alias); + updateAccountInfo(res); + void emitDailyFinished(alias); + + const st = res?.daily_clean_time?.status || ''; + if (st === '错误') { + toaster.create({ type: 'error', title: `${nameForUi}清日常结束`, description: st }); + } else if (st === '警告' || st === '中止') { + toaster.create({ type: 'warning', title: `${nameForUi}清日常完成(${st})`, description: st }); + } else if (st === '成功' || st === '跳过') { + toaster.create({ type: 'success', title: `${nameForUi}清日常成功` }); + } else { + toaster.create({ type: 'success', title: `${nameForUi}清日常完成`, description: st || undefined }); + } + } catch (err: any) { + toaster.create({ + type: 'error', + title: `${nameForUi}清日常失败`, + description: await getErrorDescription(err), + }); + } finally { + buttonLoading.onClose(); + onBusyRef.current?.(alias, false); + } + }; + + // 批量清理用:只按账号原名注册(显示名仅用于提示文案,经 ref 取最新值)。 + // 注册稳定包装(内部经 ref 取最新回调):列表重排时新卡先挂旧卡后卸,身份校验删除避免误删新注册 + const cleanDailyRef = useRef(handleCleanDaily); + cleanDailyRef.current = handleCleanDaily; + useEffect(() => { + const registered = () => cleanDailyRef.current(); + handle.set(alias, registered); + return () => { + if (handle.get(alias) === registered) handle.delete(alias); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alias]); + + const handleDeleteAccount = () => { + // 忙碌互斥:执行中账号不可删(结果未回,删了也会留下悬挂状态)。守卫读真源(含详情页登记) + if (busyAccountsRef.has(alias)) { + toaster.create({ type: 'warning', title: '该账号正在执行中,请等待执行完毕' }); + return; + } + // 删除在途同样登记忙碌:否则这几百 ms 里清理/导入/二次删除可并发发起,晚到的清理响应还会把已删账号写回成幽灵行 + onBusyRef.current?.(alias, true); + delAccount(alias) + .then((res) => { + toaster.create({ type: 'success', title: '删除账号成功', description: res }); + onToggle(); + }) + .catch(async (err: AxiosError) => { + toaster.create({ + type: 'error', + title: '删除账号失败', + description: await getErrorDescription(err), + }); + }) + .finally(() => { + onBusyRef.current?.(alias, false); + }); + }; + + const handleDailyResult = () => { + toaster.create({ type: 'info', title: `正在获取${displayNameRef.current || alias}的日常结果...` }); + getAccountDailyResultList(alias) + .then(async (res) => { + toaster.create({ type: 'success', title: '获取日常结果成功' }); + await NiceModal.show(ResultInfoModal, { alias: alias, title: '日常', resultInfo: res }); + }) + .catch(async (err: AxiosError) => { + toaster.create({ + type: 'error', + title: '获取日常结果失败', + description: await getErrorDescription(err), + }); + }); + }; + + const goDetail = () => { + void navigate({ to: `${DashBoardRoute.to || ''}${encodeURIComponent(alias)}` as any }); + }; + + + useEffect(() => { + if (isEditingName) { + // autoFocus 只在挂载时生效;编辑态切换时手动 focus + const t = window.setTimeout(() => nameInputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + } + }, [isEditingName]); + + const commitDisplayName = () => { + const next = nameDraft.trim(); + // 空名 / 等于真实 alias:恢复为 alias + if (!next || next === alias) { + safeRemoveItem(DISPLAY_NAME_KEY(alias)); + setDisplayName(alias); + setNameDraft(alias); + setIsEditingName(false); + return; + } + const occupied = getOccupiedNames(alias); + if (occupied.has(next) && next !== displayName) { + toaster.create({ + type: 'error', + title: '显示名冲突', + description: `「${next}」已被其他账号使用`, + }); + setNameDraft(displayName); + setIsEditingName(false); + return; + } + if (!safeSetItem(DISPLAY_NAME_KEY(alias), next)) { + // 存储不可用:明说。本地保留新名(会话内有效),并跳过下一次重同步(否则 effect 会读回旧值,闪一下又弹回) + toaster.create({ type: 'warning', title: '显示名保存失败', description: '本地存储不可用,本次修改仅本次会话内有效' }); + skipDisplayResyncRef.current = true; + } + setDisplayName(next); + setIsEditingName(false); + }; + commitNameRef.current = commitDisplayName; // 每渲染同步:setTimeout 兜底永远调用最新闭包 + + // 始终同一 Input:可编辑区域与名字位置重合 + const nameInput = ( + { + e.stopPropagation(); + if (!isEditingName) { + setNameDraft(displayName); + setIsEditingName(true); + } + }} + onChange={(e) => { + if (!isEditingName) return; + setNameDraft(e.target.value); + }} + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={(e) => { + composingRef.current = false; + setNameDraft((e.target as HTMLInputElement).value); + // 组词中途点到别处:compositionend 时可能已失焦,补一次提交,避免卡在编辑态 + const el = e.target as HTMLInputElement; + window.setTimeout(() => { + if (document.activeElement !== el) { + commitNameRef.current(); + } + }, 0); + }} + onBlur={() => { + if (!isEditingName) return; + if (composingRef.current) { + // 组词中 blur:等 composition 结束后由上面的 timeout 处理;再兜底一次 + window.setTimeout(() => { + if (!composingRef.current) { + commitNameRef.current(); + } + }, 0); + return; + } + commitDisplayName(); + }} + onKeyDown={(e) => { + if (!isEditingName || composingRef.current) return; + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + if (e.key === 'Escape') { + setNameDraft(displayName); + setIsEditingName(false); + } + }} + fontWeight="bold" + maxW="12em" + minW="4em" + h="2em" + px={isEditingName ? 2 : 0} + lineHeight="1" + cursor="text" + title={isEditingName ? undefined : '点击修改显示名称'} + _hover={!isEditingName ? { color: 'blue.fg' } : undefined} + borderColor={isEditingName ? undefined : 'transparent'} + boxShadow={isEditingName ? undefined : 'none'} + /> + ); + + + const handleImportConfigFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + // 忙碌互斥:该账号正有动作(清理/快捷执行/详情页清理)时拒绝导入,防两个写操作并发打后端。守卫读真源 + if (busyAccountsRef.has(alias)) { + toaster.create({ type: 'warning', title: '该账号正在执行中,请等待执行完毕' }); + return; + } + buttonLoading.onOpen(); + onBusyRef.current?.(alias, true); // 导入期间登记忙:批量动作跳过本账号(互斥对称) + try { + const rawCfg = await file.text(); + // 区服名单现查(弹窗版用 props 传入的 areas,流程本体在 accountShared.importConfigFile) + const accountDetail = await getAccount(alias); + const areas = accountDetail?.area || []; + if (areas.length === 0) { + throw new Error('该账号暂无可用区服,无法导入配置'); + } + await importConfigFile({ + alias, + rawCfg, + areas, + onFavWriteFailed: () => toaster.create({ type: 'warning', title: '配置已导入,但收藏标记保存失败(本地存储不可用)' }), + }); + toaster.create({ type: 'success', title: '配置导入成功' }); + // 失效区服配置缓存:详情页 Picker 会预热它,不清会导致详情页显示导入前的旧值、且后续拨控件以旧值为基准写回 + clearAreaConfigCache(alias); + onToggle(); + } catch (err) { + if (err instanceof AxiosError) { + toaster.create({ + type: 'error', + title: '配置导入失败', + description: await getErrorDescription(err), + }); + } else { + toaster.create({ + type: 'error', + title: '配置导入失败', + description: (err as Error).message, + }); + } + } finally { + buttonLoading.onClose(); + onBusyRef.current?.(alias, false); + } + }; + + // 函数渲染,不要内嵌组件(否则每帧新类型,按钮整卸整挂) + const renderActionButtons = ( + size: 'xs' | 'sm' | 'md' = 'xs', + flexMode = false, + ) => ( + e.stopPropagation()} + > + + + + + + + + + + importFileRef.current?.click()} + loading={buttonLoading.open} + disabled={isBusy} + > + + + + + + onOpenSyncConfig && onOpenSyncConfig(alias)} + loading={buttonLoading.open} + disabled={isBusy} + > + + + + + + + + + + + ); + + if (isTableView) { + return ( + + e.stopPropagation()}> + + + + + + + + {/* ✅ 补回内层 Flex 容器;曾用名 minW 保底,标签不挤占省略空间 */} + + + {displayName.charAt(0).toUpperCase()} + + + e.stopPropagation()} + display="flex" + alignItems="center" + lineHeight="1" + fontSize="sm" + flexShrink={1} + minW={0} + maxW={displayName !== alias ? '42%' : '70%'} + > + {nameInput} + + + {displayName !== alias && ( + + {alias} + + )} + + {/* 标签单独一组 flexShrink=0,避免反噬曾用名 */} + + + + + + e.stopPropagation()}> + + {' '} + + {/* 热区略小、叉图形略大 */} + { + e.stopPropagation(); + deleteConfirm.onOpen(); + }} + _hover={{ bg: 'red.subtle', color: 'red.fg' }} + > + + + + + + + {/* 表格状态列:保持可进详情(仅卡片状态区做安全点击区) */} + + + + + + + + {renderActionButtons('xs')} + + + ); + } + + return ( + + {/* 整卡 onClick 进详情;Header/状态/按钮区内控件自行 stopPropagation */} + + {/* 整 Header 随卡片进详情;仅选框/改名/删除 stopPropagation */} + + e.stopPropagation()} + flexShrink={0} + display="flex" + alignItems="center" + justifyContent="center" + title="选择账号" + cursor="default" + > + + + + + e.stopPropagation()} + display="flex" + alignItems="center" + lineHeight="1" + fontSize="lg" + flex="1 1 6em" + minW="4em" + maxW={displayName !== alias ? '52%' : '75%'} + overflow="hidden" + title="点击修改显示名称" + cursor="default" + > + {nameInput} + + + {displayName !== alias && ( + + {alias} + + )} + + + + + + + e.stopPropagation()} + flexShrink={0} + title="删除账号" + cursor="default" + > + + {' '} + + { + e.stopPropagation(); + deleteConfirm.onOpen(); + }} + _hover={{ bg: 'red.subtle', color: 'red.fg' }} + css={{ + '& svg': { + width: '1.4em', + height: '1.4em', + strokeWidth: 2.5, + }, + }} + > + + + + + + + {/* 整卡任意位置点击都进详情(含运行状态区) */} + + + + + 上次运行 + + + {cleanTime || '—'} + + + + + 状态 + + + + + + + + {renderActionButtons('md', true)} + + + ); +} \ No newline at end of file diff --git a/src/components/Account/AccountCardParts.tsx b/src/components/Account/AccountCardParts.tsx new file mode 100644 index 0000000..d969425 --- /dev/null +++ b/src/components/Account/AccountCardParts.tsx @@ -0,0 +1,61 @@ +/** 账号卡片/表格行共享的小 UI 件(自 AccountCard 拆出,两视图复用) */ + +import { Spinner, Tag } from '@chakra-ui/react'; +import { Checkbox } from '../ui/checkbox'; + +/** 圆形勾选框:表格行与卡片头部共用同一形状 */ +export function RoundCheckbox({ checked, onToggle }: { checked?: boolean; onToggle?: () => void }) { + return ( + + ); +} + +/** 「默认」「公会战禁用」标签组。compact=表格行(禁用用 solid+全称),否则卡片(subtle+短词) */ +export function AccountTags({ isDefault, clanForbid, compact }: { isDefault: boolean; clanForbid?: boolean; compact?: boolean }) { + return ( + <> + {isDefault && ( + + 默认 + + )} + {clanForbid && ( + + {compact ? '公会战禁用' : '禁用'} + + )} + + ); +} + +/** 状态标签:执行中转圈,否则显示最近状态 */ +export function StatusTag({ isBusy, color, icon, label }: { isBusy?: boolean; color: string; icon?: React.ReactNode; label: string }) { + if (isBusy) { + return ( + + + + + 执行中 + + ); + } + return ( + + {icon} + {label} + + ); +} diff --git a/src/components/Account/Area.tsx b/src/components/Account/Area.tsx index 76d2bab..87ae366 100644 --- a/src/components/Account/Area.tsx +++ b/src/components/Account/Area.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, IconButton, Popover, Stack, useDisclosure } from '@chakra-ui/react'; +import { Box, Button, Flex, IconButton, Popover, Stack, Text, useDisclosure } from '@chakra-ui/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FiCompass } from 'react-icons/fi'; @@ -68,15 +68,19 @@ function mergeFavIntoConfig(alias: string, key: string, res: ModuleResponse): Mo export default function Area({ alias, keys: key, areaName, showOnlyFav = false }: AreaProps) { const cached = alias && key ? getCachedAreaConfig(alias, key) : undefined; + const [retryTick, setRetryTick] = useState(0); const [state, setState] = useState<{ config: ModuleResponse | null; isLoading: boolean; + error: boolean; }>(() => ({ config: cached ?? null, isLoading: !cached, + error: false, })); const { open, onOpen, onClose } = useDisclosure(); + const [tocOpen, setTocOpen] = useState(false); useEffect(() => { let isMounted = true; @@ -88,29 +92,29 @@ export default function Area({ alias, keys: key, areaName, showOnlyFav = false } if (withFav !== hit) { setCachedAreaConfig(alias, key, withFav); } - setState({ config: withFav, isLoading: false }); + setState({ config: withFav, isLoading: false, error: false }); return () => { isMounted = false; }; } - setState({ config: null, isLoading: true }); + setState({ config: null, isLoading: true, error: false }); getAccountConfig(alias, key) .then((res) => { if (!isMounted) return; const finalRes = mergeFavIntoConfig(alias, key, res); setCachedAreaConfig(alias, key, finalRes); - setState({ config: finalRes, isLoading: false }); + setState({ config: finalRes, isLoading: false, error: false }); }) .catch((err) => { if (isMounted) { console.error(err); - setState((prev) => ({ ...prev, isLoading: false })); + setState({ config: null, isLoading: false, error: true }); toaster.create({ type: 'error', title: '加载配置失败', - description: '请检查网络后重新进入该区服', + description: '请检查网络后重试', }); } }); @@ -118,9 +122,11 @@ export default function Area({ alias, keys: key, areaName, showOnlyFav = false } return () => { isMounted = false; }; - }, [alias, key]); + }, [alias, key, retryTick]); const handleConfigUpdate = useCallback((configKey: string, value: ConfigValue) => { + // 契约(Config.useConfigSaveFlow 失败回滚守卫依赖):value 必须原引用透传,不可深拷贝—— + // 数组值(Multi/MultiSearch)的「父级值仍等于本笔乐观值」守卫用引用比较,拷贝即恒假 setState((prev) => { if (!prev.config) return prev; const nextConfig: ModuleResponse = { @@ -170,6 +176,11 @@ export default function Area({ alias, keys: key, areaName, showOnlyFav = false } )) + ) : state.error && !config ? ( + + 加载配置失败,请检查网络后重试 + + ) : ( visibleModules.map((module) => { const moduleInfo = config?.info?.[module]; @@ -204,7 +215,7 @@ export default function Area({ alias, keys: key, areaName, showOnlyFav = false } alignItems="center" zIndex={100} > - + setTocOpen(d.open)} positioning={{ placement: 'left', gutter: 4 }}> - + setTocOpen(false)} /> diff --git a/src/components/Account/Config.tsx b/src/components/Account/Config.tsx index 776b045..9f17e22 100644 --- a/src/components/Account/Config.tsx +++ b/src/components/Account/Config.tsx @@ -9,7 +9,6 @@ import { Text, Textarea, } from '@chakra-ui/react'; -import { AxiosError } from 'axios'; import NiceModal from '@ebay/nice-modal-react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { putAccountConfig } from '@/api/Account'; @@ -45,52 +44,47 @@ export function enqueueConfigSave(alias: string, task: () => Promise): Pro return next; } -/** 安全解析后端错误文案,避免 Blob/.text 抛错或 [object Object] */ -export async function getErrorDescription(err: unknown, fallback = '网络错误'): Promise { - const data = (err as { response?: { data?: unknown } })?.response?.data; - try { - if (data == null) { - if (err instanceof Error && err.message) return err.message; - return fallback; - } - if (typeof Blob !== 'undefined' && data instanceof Blob) { - const t = await data.text(); - return t || fallback; - } - if (typeof data === 'string') return data || fallback; - if (typeof data === 'object') { - try { - return JSON.stringify(data); - } catch { - return fallback; - } - } - return String(data); - } catch { - return fallback; - } -} +// 通用错误工具已迁至 accountShared;此处 import 供本文件用并 re-export 兼容既有引用 +import { getErrorDescription } from './accountShared'; +export { getErrorDescription }; + const ROW_H = '2.25rem'; const SINGLE_SEARCH_THRESHOLD = 30; -function useConfigState( +/** 统一的配置保存流:乐观写父级 → 排队保存 → 成功/失败 toast → 失败回滚父级与显示。 + * 各控件(Bool/Int/SingleSearch/Multi/Time/Text)共用,消灭五份手写模板。 */ +function useConfigSaveFlow( alias: string, key: string, - propValue: T, + propValue: ConfigValue, onConfigUpdate?: (key: string, value: ConfigValue) => void, - transform?: (val: T) => ConfigValue, ) { - const [state, setState] = useState(propValue); const mountedRef = useRef(true); - const propRef = useRef(propValue); - propRef.current = propValue; - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; - + const valueRef = useRef(propValue); + valueRef.current = propValue; + // 最后确认值(服务器已知的状态):初始=加载值,每笔保存成功推进。失败回滚一律回到它—— + // 若回到「上一笔提交值」,链式失败时那个值可能从未持久化成功,UI 会停在服务器从未见过的值上 + const lastConfirmedRef = useRef(propValue); + // 在途乐观提交的身份标记(token=对象身份,不用值——primitive payload 同值两笔必须可区分)。 + // 双用途:①effect 区分「本组件乐观回显」与「外部更新」(导入/同步),外部更新时 lastConfirmed 必须跟进; + // ②commit 各段「是不是自己」的判定(成功清标记/失败回滚守卫/失败清标记) + const inflightRef = useRef<{ payload: ConfigValue; token: object } | null>(null); + // 外部更新纪元:effect 每认定一次外部更新(导入/同步/bulk 直写)自增。commit 在途期间纪元未变时, + // 成功后即使父级已被后笔乐观值改走,lastConfirmed 也应推进到本笔 payload(服务器此刻确实是它) + const externalEpochRef = useRef(0); useEffect(() => { - setState(propValue); + // 回显判定用值比较而非「是否有在途」:有在途但 propValue 不等于在途 payload = 外部更新(导入/同步),也要跟进; + // 只跳过「恰好等于在途 payload」的乐观回显。无在途时必须显式判定(inflight !== null): + // ?.payload 的 undefined 会让「外部把值更新为 undefined」(bulk 回滚真实形态)被误判成回显而跳过跟进 + if (inflightRef.current !== null && valueRef.current === inflightRef.current.payload) return; // 自己的乐观回显 + if (valueRef.current !== lastConfirmedRef.current) { + lastConfirmedRef.current = valueRef.current; + externalEpochRef.current += 1; + } }, [propValue]); + const onUpdateRef = useRef(onConfigUpdate); + onUpdateRef.current = onConfigUpdate; useEffect(() => { mountedRef.current = true; @@ -99,30 +93,79 @@ function useConfigState( }; }, []); - const save = async (newValue: T): Promise => { - setState(newValue); - const payload = transform ? transform(newValue) : (newValue as ConfigValue); - // 先写父级缓存,折叠/切 Tab 再展开仍是新值 + /** 乐观提交一个新值,返回是否保存成功。显示态由调用方乐观设置,失败时经 onRollbackDisplay 恢复。 */ + const commit = async ( + payload: ConfigValue, + opts?: { + /** 失败时恢复组件自己的显示(参数=改动前的已保存值) */ + onRollbackDisplay?: (previous: ConfigValue) => void; + /** 回滚写回父级的值与 previous 不同时用它转换(如 Multi 的 string[]→number[]) */ + rollbackPayload?: (previous: ConfigValue) => ConfigValue; + }, + ): Promise => { + const token = {}; // 每笔唯一身份:同值两笔(blur 型控件无 dirty 检查)也能区分 + const epochAtStart = externalEpochRef.current; + inflightRef.current = { payload, token }; onUpdateRef.current?.(key, payload); try { const res = await enqueueConfigSave(alias, () => putAccountConfig(alias, key, payload)); + // 推进守卫:父级当前值仍等于本笔 payload,或本笔在途期间无外部更新(纪元未变)。 + // 后者兜住「不同值连笔」:前笔成功时父级已被后笔乐观值改走,但服务器此刻=前笔 payload, + // 不推进的话后笔失败会回滚到比服务器更旧的值(已弹过「保存成功」却显示旧值); + // 外部更新情形仍不推进——bulk 在途直写父级 V2 后本笔成功,无条件推进会把它覆盖回 V1,此后 effect 不再触发,V1 永久 stale + if (valueRef.current === payload || externalEpochRef.current === epochAtStart) { + lastConfirmedRef.current = payload; + } + if (inflightRef.current?.token === token) inflightRef.current = null; // 后笔在途时保留标记 if (mountedRef.current) { toaster.create({ type: 'success', title: '保存成功', description: res }); } + return true; } catch (err) { - const rollback = propRef.current; - onUpdateRef.current?.(key, rollback as ConfigValue); + // 回滚守卫(双重):①父级当前值仍等于本笔 payload——在途期间可能有后笔提交覆盖,无脑回滚会把后笔乐观值踩掉; + // ②在途标记仍是本笔 token——同值后笔在途时父级的值虽等于 payload,但那是后笔的乐观值: + // 本笔失败应跳过回滚(后笔自己会收尾),否则会出现「服务器=7、UI=旧值、还弹过保存成功」的 desync。 + // 契约:消费方必须传 onConfigUpdate 且父级回显同一引用,否则失败时显示回滚也会被此守卫跳过 + if (valueRef.current === payload && inflightRef.current?.token === token) { + const confirmed = lastConfirmedRef.current; + onUpdateRef.current?.(key, opts?.rollbackPayload ? opts.rollbackPayload(confirmed) : confirmed); + if (mountedRef.current) { + opts?.onRollbackDisplay?.(confirmed); + } + } + // 本笔结束:仅当在途标记仍是自己时清空(连点时后笔已覆盖标记,不能误清) + if (inflightRef.current?.token === token) inflightRef.current = null; if (mountedRef.current) { - setState(rollback); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); + toaster.create({ type: 'error', title: '保存失败', description: await getErrorDescription(err) }); } + return false; } }; + return { commit }; +} + + +function useConfigState( + alias: string, + key: string, + propValue: T, + onConfigUpdate?: (key: string, value: ConfigValue) => void, +) { + const [state, setState] = useState(propValue); + const { commit } = useConfigSaveFlow(alias, key, propValue as ConfigValue, onConfigUpdate); + + useEffect(() => { + setState(propValue); + }, [propValue]); + + const save = async (newValue: T): Promise => { + setState(newValue); + // 必须用回调入参 prev(最后确认值):闭包 propValue 是渲染时快照,链式失败时可能已是别的值—— + // 回滚显示错值正是 lastConfirmedRef 要消灭的 desync 显示态版本 + await commit(newValue as ConfigValue, { onRollbackDisplay: (prev) => setState(prev as T) }); + }; + return [state, setState, save] as const; } @@ -153,54 +196,38 @@ function ConfigInt({ alias, value, info, onConfigUpdate }: ConfigProps) { const min = candNums.length ? Math.min(...candNums) : 0; const max = candNums.length ? Math.max(...candNums) : Number.MAX_SAFE_INTEGER; - const [numStr, setNumStr] = useState(String(value)); - const mountedRef = useRef(true); - const valueRef = useRef(value); - valueRef.current = value; - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; + const [numStr, setNumStr] = useState(value === undefined ? '' : String(value)); + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { - setNumStr(String(value)); + setNumStr(value === undefined ? '' : String(value)); }, [value]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - const handleBlur = () => { - let finalValue: number; - if (numStr === '' || isNaN(Number(numStr))) { - finalValue = min; - } else { - finalValue = Number(numStr); + // 改动前已保存的值:清空/非法时恢复它,失败回滚也用它 + const previous = value; + const previousNum = typeof previous === 'number' ? previous : Number(previous); + const parsed = Number(numStr); + if (numStr === '' || isNaN(parsed)) { + // 清空或非法输入:恢复为改动前的值,不保存 + setNumStr(Number.isFinite(previousNum) ? String(previousNum) : ''); + return; } + let finalValue = Math.round(parsed); // 小数四舍五入取整 if (finalValue < min) finalValue = min; if (finalValue > max) finalValue = max; + if (Number.isFinite(previousNum) && finalValue === previousNum) { + // 内容没变:只规范显示,不发保存 + setNumStr(String(finalValue)); + return; + } setNumStr(String(finalValue)); - onUpdateRef.current?.(info.key, finalValue as ConfigValue); - - enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, finalValue as ConfigValue)) - .then((res) => { - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } - }) - .catch(async (err: AxiosError) => { - const rollback = valueRef.current; - onUpdateRef.current?.(info.key, rollback as ConfigValue); - if (mountedRef.current) { - setNumStr(String(rollback)); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } - }); + void commit(finalValue as ConfigValue, { + onRollbackDisplay: (prev) => { + const prevNum = typeof prev === 'number' ? prev : Number(prev); + setNumStr(Number.isFinite(prevNum) ? String(prevNum) : String(min)); + }, + }); }; return ( @@ -258,21 +285,12 @@ function ConfigInt({ alias, value, info, onConfigUpdate }: ConfigProps) { function ConfigSingleSearch({ alias, value, info, onConfigUpdate }: ConfigProps) { const [localValue, setLocalValue] = useState(value); - const mountedRef = useRef(true); - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { setLocalValue(value); }, [value]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - const displayText = (() => { const unit = info.candidates.find((u) => u.value === localValue); if (!unit) { @@ -295,22 +313,12 @@ function ConfigSingleSearch({ alias, value, info, onConfigUpdate }: ConfigProps) // 乐观更新显示,失败再回滚 setLocalValue(ret); - onUpdateRef.current?.(info.key, ret); - const res = await enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, ret)); - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } + await commit(ret, { onRollbackDisplay: (prev) => setLocalValue(prev) }); } catch (err) { - onUpdateRef.current?.(info.key, previousValue); + // NiceModal.show 本身的异常(如组件崩溃):恢复显示 + setLocalValue(previousValue); try { await NiceModal.hide(singleSelectModal); } catch { /* ignore */ } - if (mountedRef.current) { - setLocalValue(previousValue); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } + toaster.create({ type: 'error', title: '打开选择器失败', description: await getErrorDescription(err) }); } }; @@ -342,9 +350,11 @@ function ConfigSingleSelect({ alias, value, info, onConfigUpdate }: ConfigProps) id={info.key} value={selectValue} onChange={(e) => { - let newValue: ConfigValue = e.target.value; - const intVal = Number(newValue); - if (!isNaN(intVal)) newValue = intVal; + // 优先按候选值原本的类型保存,避免"01"这类字符串被强转成数字 + const match = info.candidates.find((c) => String(c.value) === e.target.value); + const newValue: ConfigValue = match + ? (match.value as string | number) + : (isNaN(Number(e.target.value)) ? e.target.value : Number(e.target.value)); void save(newValue); }} > @@ -357,6 +367,7 @@ function ConfigSingleSelect({ alias, value, info, onConfigUpdate }: ConfigProps) ))} + ); @@ -373,57 +384,34 @@ function ConfigSingle(props: ConfigProps) { function ConfigMulti({ alias, value, info, onConfigUpdate }: ConfigProps) { const initialStrArr = useMemo( - () => (value as (string | number)[]).map(String), + () => ((value as (string | number)[] | undefined) ?? []).map(String), // eslint-disable-next-line react-hooks/exhaustive-deps [JSON.stringify(value)], ); const [groupValue, setGroupValue] = useState(initialStrArr); - const mountedRef = useRef(true); - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; - const initialRef = useRef(initialStrArr); - initialRef.current = initialStrArr; + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { setGroupValue(initialStrArr); }, [initialStrArr]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - const handleSave = (newStrArr: string[]) => { let postValue: ConfigValue = newStrArr; const intArr = newStrArr.map(Number); if (intArr.length > 0 && intArr.every((n) => !isNaN(n))) postValue = intArr; + // 乐观回写,失败经统一流回滚(显示与父级都必须用回调入参 prev=最后确认值: + // 闭包 initialRef 是点击时快照,链式失败时可能已被前笔乐观回写污染,画出从未确认过的值) setGroupValue(newStrArr); - onUpdateRef.current?.(info.key, postValue); - enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, postValue)) - .then((res) => { - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } - }) - .catch(async (err: AxiosError) => { - const rollback = initialRef.current; - let rollbackPayload: ConfigValue = rollback; - const ints = rollback.map(Number); - if (ints.length > 0 && ints.every((n) => !isNaN(n))) rollbackPayload = ints; - onUpdateRef.current?.(info.key, rollbackPayload); - if (mountedRef.current) { - setGroupValue(rollback); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } - }); + void commit(postValue, { + onRollbackDisplay: (prev) => setGroupValue(Array.isArray(prev) ? prev.map(String) : []), + rollbackPayload: (prev) => { + const rb = Array.isArray(prev) ? prev.map(String) : []; + const ints = rb.map(Number); + return ints.length > 0 && ints.every((n) => !isNaN(n)) ? ints : rb; + }, + }); }; return ( @@ -454,45 +442,29 @@ function ConfigMulti({ alias, value, info, onConfigUpdate }: ConfigProps) { function ConfigTime({ alias, value, info, onConfigUpdate }: ConfigProps) { - const [timeStr, setTimeStr] = useState(value as string); - const mountedRef = useRef(true); - const valueRef = useRef(value); - valueRef.current = value; - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; + // 与 ConfigText 同口径兜底:后端缺失该 time 配置时 value 为 undefined,直传 Input 会变非受控组件 + const [timeStr, setTimeStr] = useState((value ?? '') as string); + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { - setTimeStr(value as string); + setTimeStr((value ?? '') as string); }, [value]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - const handleBlur = (e: React.FocusEvent) => { + const previous = (value ?? '') as string; const newValue = e.target.value; - onUpdateRef.current?.(info.key, newValue as ConfigValue); - enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, newValue as ConfigValue)) - .then((res) => { - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } - }) - .catch(async (err: AxiosError) => { - const rollback = valueRef.current as string; - onUpdateRef.current?.(info.key, rollback as ConfigValue); - if (mountedRef.current) { - setTimeStr(rollback); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } - }); + if (newValue === previous) return; // 内容没变:不保存 + const m = newValue.match(/^(\d{2}):(\d{2})$/); + if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) { + // 格式不对:恢复原值并提示,不保存 + setTimeStr(previous); + toaster.create({ type: 'warning', title: '时间格式应为 HH:MM(0-23:0-59),未保存' }); + return; + } + setTimeStr(newValue); + void commit(newValue as ConfigValue, { + onRollbackDisplay: (prev) => setTimeStr((prev ?? '') as string), + }); }; return ( @@ -512,25 +484,14 @@ function ConfigTime({ alias, value, info, onConfigUpdate }: ConfigProps) { function ConfigText({ alias, value, info, onConfigUpdate }: ConfigProps) { - const [textStr, setTextStr] = useState(value as string); + const [textStr, setTextStr] = useState((value ?? '') as string); const textareaRef = useRef(null); - const mountedRef = useRef(true); - const valueRef = useRef(value); - valueRef.current = value; - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { - setTextStr(value as string); + setTextStr((value ?? '') as string); }, [value]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - useLayoutEffect(() => { const el = textareaRef.current; if (el) { @@ -540,26 +501,13 @@ function ConfigText({ alias, value, info, onConfigUpdate }: ConfigProps) { }, [textStr]); const handleBlur = (e: React.FocusEvent) => { + const previous = (value ?? '') as string; const newValue = e.target.value; - onUpdateRef.current?.(info.key, newValue as ConfigValue); - enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, newValue as ConfigValue)) - .then((res) => { - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } - }) - .catch(async (err: AxiosError) => { - const rollback = valueRef.current as string; - onUpdateRef.current?.(info.key, rollback as ConfigValue); - if (mountedRef.current) { - setTextStr(rollback); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } - }); + if (newValue === previous) return; // 内容没变:不保存 + setTextStr(newValue); + void commit(newValue as ConfigValue, { + onRollbackDisplay: (prev) => setTextStr((prev ?? '') as string), + }); }; return ( @@ -582,21 +530,12 @@ function ConfigText({ alias, value, info, onConfigUpdate }: ConfigProps) { function ConfigMultiSearch({ alias, value, info, onConfigUpdate }: ConfigProps) { const [localValue, setLocalValue] = useState(value); - const mountedRef = useRef(true); - const onUpdateRef = useRef(onConfigUpdate); - onUpdateRef.current = onConfigUpdate; + const { commit } = useConfigSaveFlow(alias, info.key, value, onConfigUpdate); useEffect(() => { setLocalValue(value); }, [value]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - const displayValue = ((localValue || []) as number[]).map((id) => { const unit = info.candidates.find((u) => u.value === id); return unit ? (unit.nickname ? unit.nickname : unit.display) : String(id); @@ -608,28 +547,18 @@ function ConfigMultiSearch({ alias, value, info, onConfigUpdate }: ConfigProps) try { const ret = (await NiceModal.show(multiSelectModal, { candidates: info.candidates, - value: localValue as ConfigValue[], + value: (localValue ?? []) as ConfigValue[], })) as ConfigValue; if (ret === undefined) return; // show 关闭后已 resolve,成功路径不要再 hide,否则可能进 catch 误回滚 setLocalValue(ret); - onUpdateRef.current?.(info.key, ret); - const res = await enqueueConfigSave(alias, () => putAccountConfig(alias, info.key, ret)); - if (mountedRef.current) { - toaster.create({ type: 'success', title: '保存成功', description: res }); - } + await commit(ret, { onRollbackDisplay: (prev) => setLocalValue(prev) }); } catch (err) { - onUpdateRef.current?.(info.key, previousValue); + // NiceModal.show 本身的异常(如组件崩溃):恢复显示 + setLocalValue(previousValue); try { await NiceModal.hide(multiSelectModal); } catch { /* ignore */ } - if (mountedRef.current) { - setLocalValue(previousValue); - toaster.create({ - type: 'error', - title: '保存失败', - description: await getErrorDescription(err), - }); - } + toaster.create({ type: 'error', title: '打开选择器失败', description: await getErrorDescription(err) }); } }; diff --git a/src/components/Account/ConfigImportExport.tsx b/src/components/Account/ConfigImportExport.tsx index cdd2ff6..41186a9 100644 --- a/src/components/Account/ConfigImportExport.tsx +++ b/src/components/Account/ConfigImportExport.tsx @@ -6,14 +6,16 @@ import { Textarea, useDisclosure, } from "@chakra-ui/react"; -import {Candidate, ConfigType, ConfigValue, ModuleResponse} from "@interfaces/Module.ts"; +import {ConfigValue} from "@interfaces/Module.ts"; import {ChangeEvent, useRef, useState} from "react"; -import {getAccountConfig, putAccountConfigs} from "@api/Account.ts"; +import {getAccountConfig} from "@api/Account.ts"; import {AreaInfo} from "@interfaces/Account.ts"; import {AxiosError} from "axios"; +import { favKey, importConfigFile, safeGetItem, BATCH_RUNNER } from "./accountShared"; import {saveAs} from "file-saver"; import { toaster } from "@components/ui/toaster"; +import { getErrorDescription } from "./Config"; interface ConfigIOProps { alias: string; @@ -29,17 +31,21 @@ const ConfigImportExport = ({ alias, areas, onImportSuccess }: ConfigIOProps) => onOpen() void Promise.all( areas.map((area) => getAccountConfig(alias, area.key)) - ).catch((err: AxiosError) => { - toaster.create({ type: 'error', title: '配置导出失败', description: err.response?.data as string || "网络错误" }); + ).catch(async (err: AxiosError) => { + toaster.create({ type: 'error', title: '配置导出失败', description: await getErrorDescription(err) }); }).then((configs) => { if (!configs) { return; } - // 从 localStorage 读取收藏状态,合并到导出文件 - const favKey = `autopcr_fav_${alias}`; - const storedFav = localStorage.getItem(favKey); - const favMap = storedFav ? JSON.parse(storedFav) as Record : {}; + // 从 localStorage 读取收藏状态,合并到导出文件(safe 读取:隐私模式/损坏 JSON 都兜成空表,导出流程不炸) + const storedFav = safeGetItem(favKey(alias)); + let favMap: Record = {}; + try { + favMap = storedFav ? (JSON.parse(storedFav) as Record) : {}; + } catch { + favMap = {}; + } const allConfig: Record> = {}; configs.forEach((value, index) => { @@ -66,113 +72,20 @@ const ConfigImportExport = ({ alias, areas, onImportSuccess }: ConfigIOProps) => }); }; - const toCheckedConfigItem = (type: ConfigType, candidates: Candidate[], value: unknown): ConfigValue | undefined => { - switch (type) { - case 'bool': - if (typeof value === "boolean") return value; - break; - case 'single': - if (typeof value === "string" || typeof value === "number") return value; - break - case 'int': - if (typeof value === "number") return value; - break - case 'text': - if (typeof value === "string") return value; - break - case 'time': - if (typeof value === "string" && value.match(/^\d{2}:\d{2}$/) !== null) return value; - break - case 'multi': - case 'multi_search': { - if (!Array.isArray(value)) { - break - } - const checkedArray: (string | number)[] = [] - for (const item of value) { - if (typeof item !== "number" && typeof item !== "string") { - continue - } - if (candidates.find((value) => item === value.value)) { - checkedArray.push(item) - } - } - return checkedArray - } - } - return undefined - } - - const realImportByModule = (module: ModuleResponse, configs: Record): Record => { - const uploadConfig: Record = {}; - for (const moduleKey in module.info) { - if (configs[moduleKey] !== undefined && typeof configs[moduleKey] === "boolean") { - uploadConfig[moduleKey] = configs[moduleKey] - } - const moduleConf = module.info[moduleKey].config - for (const moduleConfKey in moduleConf) { - const moduleItem = moduleConf[moduleConfKey] - const confItem = toCheckedConfigItem(moduleItem.config_type, moduleItem.candidates, configs[moduleConfKey]) - if (confItem !== undefined) { - uploadConfig[moduleConfKey] = confItem - } - } - } - return uploadConfig; - } - const realImport = async (rawCfg: string) => { try { - // 优化 1:解析输入,增加对损坏文件或非法格式的捕获 - let configs: Record>; - try { - const cleanStr = rawCfg.trim(); - configs = JSON.parse(decodeURIComponent(atob(cleanStr))) as Record>; - } catch { - throw new Error("配置文件格式无效,请检查选取的配置文件或输入的文本内容。"); - } - - const configItems = await Promise.all( - areas.map((area) => getAccountConfig(alias, area.key)) - ); - const uploadConfig: Record = {}; - const importedFav: Record = {}; - - configItems.forEach((value, index) => { - const areaKey = areas[index].key; - const areaConfig = configs[areaKey]; - if (!areaConfig) { - return; - } - - // 1. Schema 校验的基础配置 - const validatedAreaConfig = realImportByModule(value, areaConfig); - Object.assign(uploadConfig, validatedAreaConfig); - - // 2. 补全收藏标记与补充细节配置 - for (const key in areaConfig) { - if (key.startsWith('_fav_')) { - importedFav[areaKey] = importedFav[areaKey] || []; - if (areaConfig[key] === true) { - importedFav[areaKey].push(key.slice(5)); - } - } else if (uploadConfig[key] === undefined && areaConfig[key] !== undefined) { - uploadConfig[key] = areaConfig[key]; - } - } + // 流程本体在 accountShared.importConfigFile(与卡片版导入共享;收藏失败降级提示) + await importConfigFile({ + alias, + rawCfg, + areas, + onFavWriteFailed: () => toaster.create({ type: 'warning', title: '配置已导入,但收藏标记保存失败(本地存储不可用)' }), }); - - // 保存收藏状态到 localStorage - localStorage.setItem(`autopcr_fav_${alias}`, JSON.stringify(importedFav)); - - await putAccountConfigs(alias, uploadConfig); toaster.create({ type: 'success', title: '配置导入成功' }); - - // 优化 2:无缝通知父级重新拉取数据刷新页面 onImportSuccess?.(); } catch (err) { if (err instanceof AxiosError) { - toaster.create({ type: 'error', title: '配置导入失败', description: err.response?.data as string || "网络错误" }); + toaster.create({ type: 'error', title: '配置导入失败', description: await getErrorDescription(err) }); } else { toaster.create({ type: 'error', title: '配置导入失败', description: (err as Error).message }); } @@ -187,9 +100,14 @@ const ConfigImportExport = ({ alias, areas, onImportSuccess }: ConfigIOProps) => if (file === undefined) { return; } - onOpen() - void file.text() - .then(realImport); + onOpen(); + file.text() + .then(realImport) + .catch(() => { + // 读取失败必须复位 loading,否则导入相关按钮永久转圈 + toaster.create({ type: 'error', title: '读取文件失败', description: '文件可能被占用或已无权限' }); + onClose(); + }); } const importTextDialogDisclosure = useDisclosure(); @@ -208,7 +126,7 @@ const ConfigImportExport = ({ alias, areas, onImportSuccess }: ConfigIOProps) => return ( <> - {alias != 'BATCH_RUNNER' && + {alias != BATCH_RUNNER && 配置导入/导出 + - + + + {quickActions.map((btn) => ( + + ))} + + setPopupResult(!!details.checked)} + colorPalette="blue" + size="md" + title="开启后,功能按钮执行完自动弹出结果汇总窗" + > + 弹结果 + + + + + - + { setIsTableView(true); - localStorage.setItem('accountViewMode', 'table'); + safeSetItem(VIEW_MODE_KEY, 'table'); }} > @@ -407,7 +678,7 @@ export function DashBoard() { colorPalette={!isTableView ? "blue" : "gray"} onClick={() => { setIsTableView(false); - localStorage.setItem('accountViewMode', 'card'); + safeSetItem(VIEW_MODE_KEY, 'card'); }} > @@ -415,43 +686,6 @@ export function DashBoard() { - {singleSelected && ( - }> - - { - NiceModal.show(ConfigSyncModal, { sourceAccount: singleSelected }); - }} - > - - - - - - - - - - )} - {userInfo?.clan && ( @@ -480,14 +714,17 @@ export function DashBoard() { colorPalette="red" onClick={() => { if (selectedAccounts.length > 0) { - if (window.confirm(`确定删除选中的 ${selectedAccounts.length} 个账号吗?`)) { + // 忙碌账号提示:正执行的账号被删,在途响应会落空(写路径互斥网不拦删除本身——删除是终止性操作,但至少告知) + const busyDel = selectedAccounts.filter((name) => busyAccountsRef.has(name)); + const busyNote = busyDel.length > 0 ? `\n其中正在执行中的账号:${busyDel.join('、')}\n(删除后这些操作的结果将丢失)` : ''; + if (window.confirm(`确定删除选中的 ${selectedAccounts.length} 个账号吗?${busyNote}`)) { Promise.all(selectedAccounts.map((name) => delAccount(name))) .then(() => { toaster.create({ type: 'success', title: '删除成功' }); setSelectedAccounts([]); freshAccountInfo.onToggle(); }) - .catch((err) => toaster.create({ type: 'error', title: '删除失败', description: (err?.response?.data as string) || '网络错误' })); + .catch(async (err) => toaster.create({ type: 'error', title: '删除失败', description: await getErrorDescription(err) })); } } else { clearAccountConfirm.onOpen(); @@ -542,17 +779,31 @@ export function DashBoard() { {' '} + { + dangerConfirm.onClose(); + setPendingDanger(null); + }} + title="执行危险功能" + body={`「${pendingDanger?.btn.name ?? ''}」为危险功能,确定要对 ${pendingDanger?.free.length ?? 0} 个账号执行吗?`} + onConfirm={onConfirmDanger} + > + {' '} + + {isTableView ? ( - + 0 && selectedAccounts.length < (userInfo?.accounts?.length ?? 0)) + (selectedAccounts.length > 0 && selectedAccounts.length < selectableNames.length) ? "indeterminate" - : (selectedAccounts.length > 0 && selectedAccounts.length === userInfo?.accounts?.length) + : allSelected } onCheckedChange={toggleSelectAll} colorPalette="blue" @@ -578,7 +829,15 @@ export function DashBoard() { - {!userInfo ? ( + {loadError && !userInfo ? ( + // 加载失败态:与「真没账号」区分,给重试出口(Area 的 error + retryTick 同款) + + + 账号列表加载失败,请检查网络后重试 + + + + ) : !userInfo ? ( Array.from({ length: 5 }).map((_, i) => ( @@ -588,19 +847,19 @@ export function DashBoard() { )) ) : ( - userInfo?.accounts?.map((account) => ( + selectableAccounts.map((account) => ( toggleSelectAccount(account.name)} - defaultAccount={userInfo?.default_account} + batchAccounts={batchAccounts} getOccupiedNames={occupiedNamesFactory} + isBusy={busyAccounts.has(account.name)} + onBusyChange={setAccountBusy} onOpenSyncConfig={(a) => { NiceModal.show(ConfigSyncModal, { sourceAccount: a }); }} @@ -611,9 +870,26 @@ export function DashBoard() { ) : ( - + + + 0 ? 'indeterminate' : false} + onCheckedChange={toggleSelectAll} + colorPalette="blue" + size="md" + > + 全选账号 + + - {!userInfo ? ( + {loadError && !userInfo ? ( + + + 账号列表加载失败,请检查网络后重试 + + + + ) : !userInfo ? ( Array.from({ length: 4 }).map((_, i) => ( @@ -622,19 +898,19 @@ export function DashBoard() { )) ) : ( - userInfo?.accounts?.map((account) => ( + selectableAccounts.map((account) => ( toggleSelectAccount(account.name)} - defaultAccount={userInfo?.default_account} + batchAccounts={batchAccounts} getOccupiedNames={occupiedNamesFactory} + isBusy={busyAccounts.has(account.name)} + onBusyChange={setAccountBusy} onOpenSyncConfig={(a) => { NiceModal.show(ConfigSyncModal, { sourceAccount: a }); }} @@ -647,781 +923,3 @@ export function DashBoard() { ); } - -interface AccountInfoProps { - account: AccountInfoInterface; - onToggle: () => void; - increaseCount: () => void; - decreaseCount: () => void; - updateAccountInfo: (updatedAccount: AccountInfoInterface) => void; - isTableView?: boolean; - isSelected?: boolean; - onToggleSelect?: () => void; - defaultAccount?: string; - onOpenSyncConfig?: (alias: string) => void; - getOccupiedNames: (selfAlias: string) => Set; -} - -function AccountInfo({ - account, - onToggle, - increaseCount, - decreaseCount, - updateAccountInfo, - isTableView = false, - isSelected = false, - onToggleSelect, - defaultAccount, - onOpenSyncConfig, - getOccupiedNames, -}: AccountInfoProps) { - const buttomLoading = useDisclosure(); - const alias = account.name; - const deleteConfirm = useDisclosure(); - const navigate = useNavigate(); - const importFileRef = useRef(null); - const cancelRef = React.useRef(null); - - const [isEditingName, setIsEditingName] = useState(false); - const [displayName, setDisplayName] = useState(() => getDisplayName(alias)); - const [nameDraft, setNameDraft] = useState(displayName); - const composingRef = useRef(false); - const nameInputRef = useRef(null); - - const clean = account.daily_clean_time; - const cleanStatus = clean?.status || '未知'; - const cleanTime = clean?.time || ''; - - const statusMeta = - cleanStatus === '成功' || cleanStatus === '跳过' - ? { color: 'green' as const, icon: , label: cleanStatus === '跳过' ? '跳过' : '完成' } - : cleanStatus === '警告' || cleanStatus === '中止' - ? { color: 'orange' as const, icon: , label: cleanStatus } - : cleanStatus === '错误' - ? { color: 'red' as const, icon: , label: '错误' } - : { color: 'gray' as const, icon: , label: cleanStatus }; - - useEffect(() => { - const latest = getDisplayName(alias); - setDisplayName(latest); - if (!isEditingName) setNameDraft(latest); - }, [alias, isEditingName]); - - const handleCleanDaily = async () => { - buttomLoading.onOpen(); - increaseCount(); - toaster.create({ type: 'info', title: `开始为${displayName || alias}清理日常...` }); - try { - const res = await postAccountAreaDaily(alias); - updateAccountInfo(res); - - const st = res?.daily_clean_time?.status || ''; - if (st === '错误') { - toaster.create({ type: 'error', title: `${displayName || alias}清日常结束`, description: st }); - } else if (st === '警告' || st === '中止') { - toaster.create({ type: 'warning', title: `${displayName || alias}清日常完成(${st})`, description: st }); - } else if (st === '成功' || st === '跳过') { - toaster.create({ type: 'success', title: `${displayName || alias}清日常成功` }); - } else { - toaster.create({ type: 'success', title: `${displayName || alias}清日常完成`, description: st || undefined }); - } - } catch (err: any) { - toaster.create({ - type: 'error', - title: `${displayName || alias}清日常失败`, - description: (err?.response?.data as string) || err?.message || '网络错误', - }); - } finally { - buttomLoading.onClose(); - decreaseCount(); - } - }; - - // 批量清理用:只在 alias/显示名变化时注册,卸载时删掉 - useEffect(() => { - handle.set(alias, handleCleanDaily as any); - return () => { - handle.delete(alias); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alias, displayName]); - - const handleDeleteAccount = () => { - delAccount(alias) - .then((res) => { - toaster.create({ type: 'success', title: '删除账号成功', description: res }); - onToggle(); - }) - .catch((err: AxiosError) => { - toaster.create({ - type: 'error', - title: '删除账号失败', - description: (err?.response?.data as string) || '网络错误', - }); - }); - }; - - const handleDailyResult = () => { - toaster.create({ type: 'info', title: `正在获取${alias}的日常结果...` }); - getAccountDailyResultList(alias) - .then(async (res) => { - toaster.create({ type: 'success', title: '获取日常结果成功' }); - await NiceModal.show(ResultInfoModal, { alias: alias, title: '日常', resultInfo: res }); - }) - .catch(async (err: AxiosError) => { - toaster.create({ - type: 'error', - title: '获取日常结果失败', - description: await getErrorDescription(err), - }); - }); - }; - - const goDetail = () => { - void navigate({ to: `${DashBoardRoute.to || ''}${alias}` as any }); - }; - - - useEffect(() => { - if (isEditingName) { - // autoFocus 只在挂载时生效;编辑态切换时手动 focus - const t = window.setTimeout(() => nameInputRef.current?.focus(), 0); - return () => window.clearTimeout(t); - } - }, [isEditingName]); - - const commitDisplayName = () => { - const next = nameDraft.trim(); - // 空名 / 等于真实 alias:恢复为 alias - if (!next || next === alias) { - localStorage.removeItem(DISPLAY_NAME_KEY(alias)); - setDisplayName(alias); - setNameDraft(alias); - setIsEditingName(false); - return; - } - const occupied = getOccupiedNames(alias); - if (occupied.has(next) && next !== displayName) { - toaster.create({ - type: 'error', - title: '显示名冲突', - description: `「${next}」已被其他账号使用`, - }); - setNameDraft(displayName); - setIsEditingName(false); - return; - } - localStorage.setItem(DISPLAY_NAME_KEY(alias), next); - setDisplayName(next); - setIsEditingName(false); - }; - - // 始终同一 Input:可编辑区域与名字位置重合 - const nameInput = ( - { - e.stopPropagation(); - if (!isEditingName) { - setNameDraft(displayName); - setIsEditingName(true); - } - }} - onChange={(e) => { - if (!isEditingName) return; - setNameDraft(e.target.value); - }} - onCompositionStart={() => { - composingRef.current = true; - }} - onCompositionEnd={(e) => { - composingRef.current = false; - setNameDraft((e.target as HTMLInputElement).value); - // 组词中途点到别处:compositionend 时可能已失焦,补一次提交,避免卡在编辑态 - const el = e.target as HTMLInputElement; - window.setTimeout(() => { - if (document.activeElement !== el) { - commitDisplayName(); - } - }, 0); - }} - onBlur={() => { - if (!isEditingName) return; - if (composingRef.current) { - // 组词中 blur:等 composition 结束后由上面的 timeout 处理;再兜底一次 - window.setTimeout(() => { - if (!composingRef.current) { - commitDisplayName(); - } - }, 0); - return; - } - commitDisplayName(); - }} - onKeyDown={(e) => { - if (!isEditingName || composingRef.current) return; - if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - if (e.key === 'Escape') { - setNameDraft(displayName); - setIsEditingName(false); - } - }} - fontWeight="bold" - maxW="12em" - minW="4em" - h="2em" - px={isEditingName ? 2 : 0} - lineHeight="1" - cursor="text" - title={isEditingName ? undefined : '点击修改显示名称'} - _hover={!isEditingName ? { color: 'blue.fg' } : undefined} - borderColor={isEditingName ? undefined : 'transparent'} - boxShadow={isEditingName ? undefined : 'none'} - /> - ); - - const toCheckedConfigItem = ( - type: ConfigType, - candidates: Candidate[], - value: unknown, - ): ConfigValue | undefined => { - switch (type) { - case 'bool': - case 'single': - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value as ConfigValue; - } - break; - case 'int': - if (typeof value === 'number') return value; - break; - case 'text': - if (typeof value === 'string') return value; - break; - case 'time': - if (typeof value === 'string' && value.match(/^\d{2}:\d{2}$/) !== null) return value; - break; - case 'multi': - case 'multi_search': { - if (!Array.isArray(value)) break; - const checkedArray: (string | number)[] = []; - for (const item of value) { - if (typeof item !== 'number' && typeof item !== 'string') continue; - if (candidates.find((v) => item === v.value)) checkedArray.push(item); - } - return checkedArray; - } - } - return undefined; - }; - - const realImportByModule = ( - module: ModuleResponse, - configs: Record, - ): Record => { - const uploadConfig: Record = {}; - for (const moduleKey in module.info) { - if (configs[moduleKey] !== undefined && typeof configs[moduleKey] === 'boolean') { - uploadConfig[moduleKey] = configs[moduleKey]; - } - const moduleConf = module.info[moduleKey].config; - for (const moduleConfKey in moduleConf) { - const moduleItem = moduleConf[moduleConfKey]; - const confItem = toCheckedConfigItem( - moduleItem.config_type, - moduleItem.candidates, - configs[moduleConfKey], - ); - if (confItem !== undefined) { - uploadConfig[moduleConfKey] = confItem; - } - } - } - return uploadConfig; - }; - - const handleImportConfigFile = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) return; - - buttomLoading.onOpen(); - try { - const rawCfg = await file.text(); - let configs: Record>; - try { - configs = JSON.parse(decodeURIComponent(atob(rawCfg.trim()))) as Record< - string, - Record - >; - } catch { - throw new Error('配置文件格式无效,请检查选取的配置文件。'); - } - - const accountDetail = await getAccount(alias); - const areas = accountDetail?.area || []; - if (!areas.length) { - throw new Error('该账号暂无可用区服,无法导入配置'); - } - - const configItems = await Promise.all( - areas.map((area: { key: string }) => getAccountConfig(alias, area.key)), - ); - const uploadConfig: Record = {}; - const importedFav: Record = {}; - - configItems.forEach((value, index) => { - const areaKey = areas[index].key; - const areaConfig = configs[areaKey]; - if (!areaConfig) return; - - Object.assign(uploadConfig, realImportByModule(value, areaConfig)); - - for (const key in areaConfig) { - if (key.startsWith('_fav_')) { - importedFav[areaKey] = importedFav[areaKey] || []; - if (areaConfig[key] === true) { - importedFav[areaKey].push(key.slice(5)); - } - } else if (uploadConfig[key] === undefined && areaConfig[key] !== undefined) { - uploadConfig[key] = areaConfig[key]; - } - } - }); - - localStorage.setItem(`autopcr_fav_${alias}`, JSON.stringify(importedFav)); - await putAccountConfigs(alias, uploadConfig); - toaster.create({ type: 'success', title: '配置导入成功' }); - onToggle(); - } catch (err) { - if (err instanceof AxiosError) { - toaster.create({ - type: 'error', - title: '配置导入失败', - description: (err.response?.data as string) || '网络错误', - }); - } else { - toaster.create({ - type: 'error', - title: '配置导入失败', - description: (err as Error).message, - }); - } - } finally { - buttomLoading.onClose(); - } - }; - - // 函数渲染,不要内嵌组件(否则每帧新类型,按钮整卸整挂) - const renderActionButtons = ( - size: 'xs' | 'sm' | 'md' = 'xs', - flexMode = false, - ) => ( - e.stopPropagation()} - > - - - - - - - - - - importFileRef.current?.click()} - loading={buttomLoading.open} - > - - - - - - onOpenSyncConfig && onOpenSyncConfig(alias)} - loading={buttomLoading.open} - > - - - - - - - - - - - ); - - if (isTableView) { - return ( - - e.stopPropagation()}> - - - - - - - - {/* ✅ 补回内层 Flex 容器;曾用名 minW 保底,标签不挤占省略空间 */} - - - {displayName.charAt(0).toUpperCase()} - - - e.stopPropagation()} - display="flex" - alignItems="center" - lineHeight="1" - fontSize="sm" - flexShrink={1} - minW={0} - maxW={displayName !== alias ? '42%' : '70%'} - > - {nameInput} - - - {displayName !== alias && ( - - {alias} - - )} - - {/* 标签单独一组 flexShrink=0,避免反噬曾用名 */} - - {defaultAccount === account.name && ( - - 默认 - - )} - {account.clan_forbid && ( - - 公会战禁用 - - )} - - - - e.stopPropagation()}> - - {' '} - - {/* 热区略小、叉图形略大 */} - { - e.stopPropagation(); - deleteConfirm.onOpen(); - }} - _hover={{ bg: 'red.subtle', color: 'red.fg' }} - > - - - - - - - {/* 表格状态列:保持可进详情(仅卡片状态区做安全点击区) */} - - - - {statusMeta.icon} - - {statusMeta.label} - {cleanTime ? ` ${cleanTime}` : ''} - - - - - - - {renderActionButtons('xs')} - - - ); - } - - return ( - - {/* 整卡 onClick 进详情;Header/状态/按钮区内控件自行 stopPropagation */} - - {/* 整 Header 随卡片进详情;仅选框/改名/删除 stopPropagation */} - - e.stopPropagation()} - flexShrink={0} - display="flex" - alignItems="center" - justifyContent="center" - title="选择账号" - cursor="default" - > - - - - - e.stopPropagation()} - display="flex" - alignItems="center" - lineHeight="1" - fontSize="lg" - flex="1 1 6em" - minW="4em" - maxW={displayName !== alias ? '52%' : '75%'} - overflow="hidden" - title="" - cursor="default" - > - {nameInput} - - - {displayName !== alias && ( - - {alias} - - )} - - - {defaultAccount === account.name && ( - - 默认 - - )} - {account.clan_forbid && ( - - 禁用 - - )} - - - - e.stopPropagation()} - flexShrink={0} - title="删除账号" - cursor="default" - > - - {' '} - - { - e.stopPropagation(); - deleteConfirm.onOpen(); - }} - _hover={{ bg: 'red.subtle', color: 'red.fg' }} - css={{ - '& svg': { - width: '1.4em', - height: '1.4em', - strokeWidth: 2.5, - }, - }} - > - - - - - - - {/* 仅灰底窗体本身不进详情;Body 上下/左右一丝空白仍进详情 */} - - e.stopPropagation()} - > - - - 上次运行 - - - {cleanTime || '—'} - - - - - 状态 - - - {statusMeta.icon} - {cleanStatus} - - - - - - - {renderActionButtons('md', true)} - - - ); -} diff --git a/src/components/Account/Info.tsx b/src/components/Account/Info.tsx index 2a6409e..9462e5a 100644 --- a/src/components/Account/Info.tsx +++ b/src/components/Account/Info.tsx @@ -19,6 +19,8 @@ import { AxiosError } from 'axios'; import { Checkbox } from '../../components/ui/checkbox'; import { Field } from '../../components/ui/field'; import { putAccount } from '@/api/Account'; +import { getErrorDescription } from './Config'; +import { BATCH_RUNNER, getDisplayName } from './accountShared'; import { toaster } from '../../components/ui/toaster'; interface InfoProps { @@ -34,8 +36,8 @@ const fadeEntry = keyframes` export default function Info({ accountInfo, onSaveSuccess }: InfoProps) { const alias = accountInfo?.alias || ''; const displayName = - alias && alias !== 'BATCH_RUNNER' - ? (localStorage.getItem(`autopcr_displayName_${alias}`) || alias) + alias && alias !== BATCH_RUNNER + ? getDisplayName(alias) // safe 封装:渲染期裸读 localStorage 在隐私模式会抛 SecurityError 白屏 : alias; const [username, setUsername] = useState(accountInfo?.username); @@ -71,8 +73,8 @@ export default function Info({ accountInfo, onSaveSuccess }: InfoProps) { onSaveSuccess(); } }) - .catch((err: AxiosError) => { - toaster.create({ title: '保存失败', description: (err.response?.data as string) || '网络错误', type: 'error' }); + .catch(async (err: AxiosError) => { + toaster.create({ title: '保存失败', description: await getErrorDescription(err), type: 'error' }); }) .finally(() => { onClose(); @@ -129,9 +131,9 @@ export default function Info({ accountInfo, onSaveSuccess }: InfoProps) { > - {alias === 'BATCH_RUNNER' ? '批量运行配置' : displayName} + {alias === BATCH_RUNNER ? '批量运行配置' : displayName} - {accountInfo?.alias !== 'BATCH_RUNNER' && ( + {accountInfo?.alias !== BATCH_RUNNER && ( 基础信息配置 @@ -140,7 +142,7 @@ export default function Info({ accountInfo, onSaveSuccess }: InfoProps) {
- {accountInfo?.alias !== 'BATCH_RUNNER' && ( + {accountInfo?.alias !== BATCH_RUNNER && ( <> ))} + @@ -191,7 +194,7 @@ export default function Info({ accountInfo, onSaveSuccess }: InfoProps) { )} - {accountInfo?.alias === 'BATCH_RUNNER' && ( + {accountInfo?.alias === BATCH_RUNNER && ( { alias: string, @@ -26,14 +28,58 @@ interface ModuleProps extends React.ComponentProps { } export default function Module({ alias, areaKey, areaName, config, info, isOpen, onOpen, onClose, onConfigUpdate, ...rest }: ModuleProps) { + + /** 一键把炼成属性1-4全部设为同一属性(2物攻 4魔攻 12物贯 13法贯),乐观回写+失败回滚(仅还原仍等于乐观值的键,避免覆盖用户手改) */ + const bulkBusyRef = useRef(false); + const [bulkBusy, setBulkBusy] = useState(false); // 视觉反馈:执行中按钮转圈+全组禁用 + // 回滚判定要读“此刻”的配置:闭包里的 config 是 await 前的旧值,守卫会恒 false 导致回滚失效 + const configRef = useRef(config); + configRef.current = config; + const handleBulkSubStatus = async (value: number): Promise => { + if (bulkBusyRef.current) return; // 防连点:上一批还在队列里 + bulkBusyRef.current = true; + setBulkBusy(true); + try { + const keys = [ + 'ex_equip_rainbow_enchance_sub_status_1', + 'ex_equip_rainbow_enchance_sub_status_2', + 'ex_equip_rainbow_enchance_sub_status_3', + 'ex_equip_rainbow_enchance_sub_status_4', + ]; + const optimistic: Record = {}; + const next: Record = {}; + for (const k of keys) { + optimistic[k] = config[k]; + next[k] = value; + } + for (const k of keys) onConfigUpdate?.(k, next[k]); + try { + const res = await enqueueConfigSave(alias, () => putAccountConfigs(alias, next)); + toaster.create({ type: 'success', title: '保存成功', description: res }); + } catch (err) { + // 只还原当前值仍等于乐观写入值的键:排队期间用户手改过的键不动(configRef=最新渲染值) + for (const k of keys) { + if (configRef.current[k] === next[k]) onConfigUpdate?.(k, optimistic[k]); + } + toaster.create({ + type: 'error', + title: '保存失败', + description: await getErrorDescription(err as AxiosError), + }); + } + } finally { + bulkBusyRef.current = false; + setBulkBusy(false); + } + }; const { open: isExpanded, onToggle: onToggleExpand } = useDisclosure({ defaultOpen: false }); const dangerConfirm = useDisclosure(); - const isDangerous = areaName === '危险'; + const isDangerous = areaName === DANGEROUS_AREA_NAME; const handleToggleFav = async (e: React.MouseEvent) => { e.stopPropagation(); - const favKey = `autopcr_fav_${alias}`; - const stored = localStorage.getItem(favKey); + const favKeyValue = favKey(alias); + const stored = safeGetItem(favKeyValue); let favMap: Record = {}; if (stored) { try { @@ -52,9 +98,7 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, } favMap[areaKey] = Array.from(areaFavs); - try { - localStorage.setItem(favKey, JSON.stringify(favMap)); - } catch { + if (!safeSetItem(favKeyValue, JSON.stringify(favMap))) { toaster.create({ type: 'error', title: '收藏保存失败', description: '本地存储不可用或已满' }); return; } @@ -83,15 +127,26 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, }; const handleExecute = () => { + // 忙碌互斥第七条路径:单模块执行与清理/批量是同一族长任务,在途时其它写路径必须被拒 + if (busyAccountsRef.has(alias)) { + toaster.create({ type: 'warning', title: '该账号正在执行中', description: '请等待当前操作完成' }); + return; + } + patchBusy(alias, true); toaster.create({ type: 'info', title: '开始执行' + info?.name + "..." }); onOpen(); postAccountAreaSingle(alias, info?.key).then(async (res) => { toaster.create({ type: 'success', title: '执行成功' }); - await NiceModal.show(ResultInfoModal, { alias: alias, title: info?.name, resultInfo: res }); + // 先解除 loading 再弹结果:无论结果窗怎么关,按钮圈都会正常结束 + onClose(); + if (loadPopupFlag(alias)) { + await NiceModal.show(ResultInfoModal, { alias: alias, title: info?.name, resultInfo: res }); + } }).catch(async (err: AxiosError) => { toaster.create({ type: 'error', title: '执行失败', description: await getErrorDescription(err) }); }).finally(() => { onClose(); + patchBusy(alias, false); // 本笔是唯一登记作者(入口已互斥),直接清 }); } @@ -100,6 +155,8 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, toaster.create({ type: 'info', title: `正在获取${info?.name}的结果` }); onOpen(); getAccountAreaSingleResultList(alias, info?.key).then(async (res) => { + // 先解除 loading 再弹结果 + onClose(); await NiceModal.show(ResultInfoModal, { alias: alias, title: info?.name, resultInfo: res }); }).catch(async (err: AxiosError) => { toaster.create({ type: 'error', title: '获取结果失败', description: await getErrorDescription(err) }); @@ -134,6 +191,8 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, } onOpen(); + // 互斥入网:源账号登记(ModuleSyncModal 只过滤不登记),PUT 前逐目标复查(弹窗确认期间目标可能开始执行) + patchBusy(alias, true); try { const moduleRes = await getAccountConfig(alias, "daily"); if (!moduleRes.config) { @@ -162,9 +221,15 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, let successCount = 0; let failCount = 0; + let skipCount = 0; for (const targetAccount of normalizedTargets) { + if (busyAccountsRef.has(targetAccount)) { + skipCount++; // 忙碌=跳过不记失败(与配置同步弹窗同口径) + continue; + } try { await putAccountConfigs(targetAccount, filteredConfig); + clearAreaConfigCache(targetAccount); // 失效目标 Area 缓存,防旧值回写冲掉刚同步的配置 successCount++; } catch (e) { console.error(`Error syncing to ${targetAccount}`, e); @@ -173,15 +238,17 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, } if (failCount === 0) { - toaster.create({ type: 'success', title: `成功同步 ${info?.name} 到 ${successCount} 个账号` }); + const skipNote = skipCount > 0 ? `,跳过(执行中): ${skipCount}` : ''; + toaster.create({ type: 'success', title: `成功同步 ${info?.name} 到 ${successCount} 个账号${skipNote}` }); } else { - toaster.create({ type: 'warning', title: `同步部分完成`, description: `成功: ${successCount}, 失败: ${failCount}` }); + toaster.create({ type: 'warning', title: `同步部分完成`, description: `成功: ${successCount}, 失败: ${failCount}${skipCount > 0 ? `, 跳过: ${skipCount}` : ''}` }); } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); toaster.create({ type: 'error', title: '同步过程中发生错误', description: errorMessage }); } finally { onClose(); + patchBusy(alias, false); // 本笔是源账号登记的唯一作者,直接清 } } @@ -273,13 +340,36 @@ export default function Module({ alias, areaKey, areaName, config, info, isOpen, 设置项 { info?.config_order.map((key) => ( - + + + {key === 'ex_equip_rainbow_enchance_sub_status_4' && ( + + {([ + { label: '全部物攻', value: 2 }, + { label: '全部魔攻', value: 4 }, + { label: '全部物贯', value: 12 }, + { label: '全部法贯', value: 13 }, + ] as const).map((opt) => ( + + ))} + + )} + )) } diff --git a/src/components/Account/ModuleSyncModal.tsx b/src/components/Account/ModuleSyncModal.tsx index 560b7f4..4b065a1 100644 --- a/src/components/Account/ModuleSyncModal.tsx +++ b/src/components/Account/ModuleSyncModal.tsx @@ -20,6 +20,8 @@ import { getUserInfo } from '@api/Account'; import { useEffect, useState } from 'react'; import { Checkbox } from '../../components/ui/checkbox'; import { toaster } from '../../components/ui/toaster'; +import { getErrorDescription } from './Config'; +import { BATCH_RUNNER, busyAccountsRef } from './accountShared'; interface ModuleSyncModalProps { sourceAlias: string; @@ -38,11 +40,11 @@ export default NiceModal.create(({ sourceAlias, moduleName }: ModuleSyncModalPro setIsLoading(true); try { const userInfo = await getUserInfo(); - const accounts = userInfo?.accounts?.map(acc => acc.name).filter(name => name !== sourceAlias) || []; + const accounts = userInfo?.accounts?.map(acc => acc.name).filter(name => name !== sourceAlias && name !== BATCH_RUNNER) || []; setAllAccounts(accounts); setSelectedTargets(accounts); } catch (err) { - toaster.create({ type: 'error', title: '获取账号列表失败', description: String(err) }); + toaster.create({ type: 'error', title: '获取账号列表失败', description: await getErrorDescription(err) }); } finally { setIsLoading(false); } @@ -72,7 +74,17 @@ export default NiceModal.create(({ sourceAlias, moduleName }: ModuleSyncModalPro toaster.create({ type: 'warning', title: '请选择至少一个目标账号' }); return; } - modal.resolve(selectedTargets); + // 忙碌目标过滤:与配置同步弹窗同口径(目标正在执行时同步 PUT 会并发打后端) + const busyTargets = selectedTargets.filter((t) => busyAccountsRef.has(t)); + const freeTargets = selectedTargets.filter((t) => !busyAccountsRef.has(t)); + if (freeTargets.length === 0) { + toaster.create({ type: 'warning', title: '请等待执行完毕', description: '所选目标账号都正在执行中' }); + return; + } + if (busyTargets.length > 0) { + toaster.create({ type: 'info', title: `模块同步:已跳过 ${busyTargets.length} 个正在执行中的账号` }); + } + modal.resolve(freeTargets); modal.hide(); }; diff --git a/src/components/Account/MultiSelectModal.tsx b/src/components/Account/MultiSelectModal.tsx index cdee2d0..d308600 100644 --- a/src/components/Account/MultiSelectModal.tsx +++ b/src/components/Account/MultiSelectModal.tsx @@ -22,8 +22,9 @@ interface MultiSelectModalProps { const multiSelectModal = NiceModal.create(({ candidates, value }: MultiSelectModalProps) => { const modal = useModal(); - const [selectedUnits, setSelectedUnits] = useState(value); - const [availableUnits, setAvailableUnits] = useState(candidates.filter((u) => !value.includes(u.value))); + const initValue = Array.isArray(value) ? value : []; + const [selectedUnits, setSelectedUnits] = useState(initValue); + const [availableUnits, setAvailableUnits] = useState(candidates.filter((u) => !initValue.includes(u.value))); const [searchAllText, setSearchAllText] = useState(''); const [searchSelectedText, setSearchSelectedText] = useState(''); const [draggedUnit, setDraggedUnit] = useState(null); @@ -31,8 +32,8 @@ const multiSelectModal = NiceModal.create(({ candidates, value }: MultiSelectMod const lastVisibleRef = useRef(false); if (modal.visible && !lastVisibleRef.current) { - setSelectedUnits(value); - setAvailableUnits(candidates.filter(u => !value.includes(u.value))); + setSelectedUnits(initValue); + setAvailableUnits(candidates.filter(u => !initValue.includes(u.value))); setSearchAllText(''); setSearchSelectedText(''); } @@ -111,7 +112,7 @@ const multiSelectModal = NiceModal.create(({ candidates, value }: MultiSelectMod ); return ( - + 选择 diff --git a/src/components/Account/QuickActionPicker.tsx b/src/components/Account/QuickActionPicker.tsx new file mode 100644 index 0000000..2ac69fb --- /dev/null +++ b/src/components/Account/QuickActionPicker.tsx @@ -0,0 +1,261 @@ +import { Box, Button, Flex, Input, Text } from '@chakra-ui/react'; +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '../../components/ui/modal'; +import { Checkbox } from '../../components/ui/checkbox'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getAccount, getAccountConfig } from '@api/Account'; +import { ModuleInfo } from '@interfaces/Module'; +import { Skeleton } from '../../components/ui/skeleton'; +import { toaster } from '../../components/ui/toaster'; +import { QuickActionItem } from './quickActions'; +import { getCachedAreaConfig, setCachedAreaConfig } from './Area'; +import { DANGEROUS_AREA_NAME } from './accountShared'; + +interface QuickActionPickerProps { + /** 参考账号:功能定义全服一致,取任意一个真实账号拉取 */ + alias: string; + current: QuickActionItem[]; +} + +interface PickerGroup { + areaKey: string; + areaName: string; + modules: { key: string; name: string; dangerous: boolean }[]; +} + +const QuickActionPicker = NiceModal.create(({ alias, current }: QuickActionPickerProps) => { + const modal = useModal(); + const [groups, setGroups] = useState([]); + // 拉取失败独立于「列表为空」:合法空列表(区服空/模块全下线)允许确认(那是移除旧按钮的唯一途径),失败列表不行 + const [fetchFailed, setFetchFailed] = useState(false); + const [selected, setSelected] = useState>(() => new Set(current.map((b) => b.key))); + const [searchText, setSearchText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + // 失败重试:递增触发重新拉取 + const [reloadTick, setReloadTick] = useState(0); + + // 重开边沿重置(同 MultiSelectModal):hide() 不卸载组件,取消/关闭后残留的勾选与搜索词 + // 会污染下次打开的初始态——「取消」语义失效,误按确定会把实验态保存为正式按钮集 + const lastVisibleRef = useRef(false); + if (modal.visible && !lastVisibleRef.current) { + setSelected(new Set(current.map((b) => b.key))); + setSearchText(''); + } + lastVisibleRef.current = modal.visible; + + useEffect(() => { + if (!modal.visible || !alias) return; + let isMounted = true; + (async () => { + setIsLoading(true); + try { + // 打开即校验:本地已存但后端已消失的功能在这里被自然排除 + const detail = await getAccount(alias); + const areas = detail?.area || []; + // 限并发拉取:最多 3 路在途(服务器部署也扛得住),比纯串行快数倍;结果按 areas 原序归位 + const FETCH_CONCURRENCY = 3; + const results: Awaited>[] = new Array(areas.length); + let cursor = 0; + const worker = async () => { + while (cursor < areas.length) { + const i = cursor++; + const area = areas[i]; + const cached = getCachedAreaConfig(alias, area.key); + const res = cached ?? (await getAccountConfig(alias, area.key)); + if (!cached) setCachedAreaConfig(alias, area.key, res); + results[i] = res; + } + }; + await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, areas.length) }, worker)); + const built: PickerGroup[] = areas.map((area, i) => { + const res = results[i]; + const mods = (res?.order || []) + .map((k) => res.info?.[k]) + .filter((m): m is ModuleInfo => !!m && m.implemented && m.runnable) + .map((m) => ({ key: m.key, name: m.name, dangerous: area.name === DANGEROUS_AREA_NAME })); + return { areaKey: area.key, areaName: area.name, modules: mods }; + }); + if (isMounted) setGroups(built); + if (isMounted) setFetchFailed(false); + } catch (err) { + // 拉取失败必须清空列表:modal 关闭不卸载,留着旧数据会渲染陈旧功能并可确认写回(后端已下线的功能混进快捷栏) + if (isMounted) { + setGroups([]); + setFetchFailed(true); + toaster.create({ type: 'error', title: '获取功能列表失败', description: '请检查网络后重试' }); + } + } finally { + if (isMounted) setIsLoading(false); + } + })(); + return () => { + isMounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modal.visible, alias, reloadTick]); + + const q = searchText.trim().toLowerCase(); + const filtered = useMemo( + () => + groups + .map((g) => ({ + ...g, + modules: q + ? g.modules.filter( + (m) => m.name.toLowerCase().includes(q) || m.key.toLowerCase().includes(q), + ) + : g.modules, + })) + .filter((g) => g.modules.length > 0), + [groups, q], + ); + + const toggle = (key: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const toggleGroup = (group: PickerGroup) => { + const keys = group.modules.map((m) => m.key); + const allIn = keys.every((k) => selected.has(k)); + setSelected((prev) => { + const next = new Set(prev); + keys.forEach((k) => (allIn ? next.delete(k) : next.add(k))); + return next; + }); + }; + + const handleConfirm = () => { + if (fetchFailed) return; // 拉取失败不允许确认;合法空列表可以(确认=清空全部按钮) + // 顺序=列表顺序(日常在前);同 key 跨区服只保留首个,避免重复按钮 + const items: QuickActionItem[] = []; + const seen = new Set(); + groups.forEach((g) => + g.modules.forEach((m) => { + if (selected.has(m.key) && !seen.has(m.key)) { + seen.add(m.key); + items.push({ key: m.key, name: m.name, areaKey: g.areaKey, areaName: g.areaName, dangerous: m.dangerous }); + } + }), + ); + // 后端已下线的功能:勾了也带不回来,单独提示(名字从旧按钮缓存取;用户主动取消勾选不在此列,不提示) + const vanished = [...selected].filter((k) => !seen.has(k)); + if (vanished.length > 0) { + const knownNames = current.filter((b) => vanished.includes(b.key)).map((b) => b.name); + const unknown = vanished.length - knownNames.length; + const head = knownNames.join('、'); + const tail = unknown > 0 ? ` 等 ${vanished.length} 个功能` : ''; + toaster.create({ type: 'warning', title: '部分功能已失效', description: `${head}${tail} 在后端已不存在,未能添加` }); + // 剪掉幽灵键:它们不在任何分组里、无法被取消勾选,不剪会每次确认都重复弹「无名」警告 + setSelected(new Set(seen)); + } + modal.resolve(items); + void modal.hide(); + }; + + const handleClose = () => { + modal.resolve(undefined); + void modal.hide(); + }; + + return ( + + + + 增加功能 + + + setSearchText(e.target.value)} + autoFocus + /> + + {isLoading && ( + + + + + + )} + {!isLoading && + filtered.map((g) => { + const keys = g.modules.map((m) => m.key); + const inCount = keys.filter((k) => selected.has(k)).length; + return ( + + + + {g.areaName} + + 0 ? true : inCount > 0 ? 'indeterminate' : false} + onCheckedChange={() => toggleGroup(g)} + > + 全选 + + + + {g.modules.map((m) => ( + toggle(m.key)} + > + {m.name} + + ))} + + + ); + })} + {!isLoading && fetchFailed && ( + // 失败态专属文案 + 重试:与「合法空列表」(可确认清空)在行为层和视觉层都分流, + // 否则想清空按钮的用户会误以为功能已全下线(审计十 #8) + + 功能列表获取失败,请检查网络后重试 + + + )} + {!isLoading && !fetchFailed && filtered.length === 0 && ( + + 无匹配功能 + + )} + + + 勾选后点确定,按钮会出现在主页工具栏中间;取消勾选即移除。 + + + + + + + + + ); +}); + +export default QuickActionPicker; diff --git a/src/components/Account/ResultInfoModal.tsx b/src/components/Account/ResultInfoModal.tsx index 2a9599b..bd257a7 100644 --- a/src/components/Account/ResultInfoModal.tsx +++ b/src/components/Account/ResultInfoModal.tsx @@ -20,7 +20,7 @@ interface ModalProps { const resultModal = NiceModal.create(({ alias, title, resultInfo }: ModalProps) => { const modal = useModal(); return ( - + { modal.resolve(); await modal.hide(); }}> {alias}的{title}结果 diff --git a/src/components/Account/ResultSummaryModal.tsx b/src/components/Account/ResultSummaryModal.tsx new file mode 100644 index 0000000..d77e7fa --- /dev/null +++ b/src/components/Account/ResultSummaryModal.tsx @@ -0,0 +1,83 @@ +import { Box, Button, Flex, Text } from '@chakra-ui/react'; +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '../../components/ui/modal'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; + +export interface ResultSummaryRow { + alias: string; + name: string; + status: '成功' | '失败'; + /** 失败原因摘要(成功时为空) */ + detail?: string; +} + +interface ResultSummaryProps { + title: string; + rows: ResultSummaryRow[]; +} + +/** 执行结果汇总窗:一行一个账号(可带失败原因),替代原批量结果表 */ +const ResultSummaryModal = NiceModal.create(({ title, rows }: ResultSummaryProps) => { + const modal = useModal(); + const okCount = rows.filter((r) => r.status === '成功').length; + const failCount = rows.length - okCount; + const finish = async () => { + modal.resolve(); + await modal.hide(); + }; + return ( + + + + {title} + + + + 成功 {okCount} / 失败 {failCount} + + + {rows.map((row) => ( + + + {row.name} + + + {row.detail && ( + + {row.detail} + + )} + + {row.status} + + + + ))} + {rows.length === 0 && ( + + 本次没有可执行的账号 + + )} + + + 关闭后可在各账号详情的功能区里查看完整结果。 + + + + + + + + ); +}); + +export default ResultSummaryModal; diff --git a/src/components/Account/ScheduleNotify.tsx b/src/components/Account/ScheduleNotify.tsx new file mode 100644 index 0000000..fd239fd --- /dev/null +++ b/src/components/Account/ScheduleNotify.tsx @@ -0,0 +1,453 @@ +/** + * 半月刊通知(原「周期通知」整体重构,审计十一后用户裁决): + * - 数据源:后端 /daily/api/schedule(半月刊结构化日程,{key, category, start_time, end_time, description}) + * - 通知触发:勾选类别的条目在开始日当天,按设置的 HH:mm 弹浏览器通知(每日至多一次,key 去重) + * - 「正在进行」常驻:下拉菜单里显示勾选类别中当前开放的所有条目(MM:DD-MM:DD,不显时分) + * - 刷新时机:本地缓存无过期条目时每天至多拉一次;有活动结束(end < today)立即重拉 + * - 旧警报轮询已整体移除(emitDailyFinished 事件链保留给其他消费者) + */ +import { Box, Button, Flex, HStack, Popover, Stack, Text } from '@chakra-ui/react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { FiBell } from 'react-icons/fi'; +import { API } from '@api/APIUtils'; +import { safeGetItem, safeSetItem } from './accountShared'; +import { Checkbox } from '../../components/ui/checkbox'; + +/** 后端 schedule_entries 单条(与半月刊渲染同源) */ +export interface ScheduleEntry { + key: string; + /** 半月刊类别名:公会战/扭蛋/庆典/活动/新斗技场/... */ + category: string; + start_time: string; // YYYY/MM/DD + end_time: string; // YYYY/MM/DD + description: string; +} + +const PREFS_KEY = 'autopcr_schedule_notify_v1'; +const NOTIFIED_KEY = 'autopcr_schedule_notified_v1'; + +export interface ScheduleNotifyPrefs { + enabled: boolean; + /** 勾选的通知类别(半月刊 category 名) */ + categories: string[]; + /** 开始日当天的提醒时刻(本地时区 HH:mm,24h 制) */ + notifyTime: string; +} + +const DEFAULT_PREFS: ScheduleNotifyPrefs = { enabled: false, categories: [], notifyTime: '08:00' }; + +export function loadSchedulePrefs(): ScheduleNotifyPrefs { + try { + const raw = safeGetItem(PREFS_KEY); + const parsed = raw ? (JSON.parse(raw) as Partial) : null; + return { + enabled: !!parsed?.enabled, + categories: Array.isArray(parsed?.categories) ? parsed.categories.filter((x): x is string => typeof x === 'string') : [], + notifyTime: typeof parsed?.notifyTime === 'string' && /^\d{2}:\d{2}$/.test(parsed.notifyTime) ? parsed.notifyTime : DEFAULT_PREFS.notifyTime, + }; + } catch { + return { ...DEFAULT_PREFS }; + } +} + +export function saveSchedulePrefs(prefs: ScheduleNotifyPrefs): boolean { + return safeSetItem(PREFS_KEY, JSON.stringify(prefs)); +} + +function loadNotified(): Record { + try { + const raw = safeGetItem(NOTIFIED_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function saveNotified(map: Record): void { + safeSetItem(NOTIFIED_KEY, JSON.stringify(map)); +} + +/** 本地今天 YYYY/MM/DD(与后端 start_time 字符串直接比较,不做时区换算——后端即本地时区时间戳) */ +function todayStr(): string { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())}`; +} + +/** 全部类别的固定清单与展示顺序(面板勾选区固定全量显示,与数据有无无关) */ +const CATEGORY_ORDER = [ + '活动', '女神祭', '庆典', '扭蛋', '免费十连', + '公会战', '特别地下城', '新斗技场', '季卡驾车游', + '露娜塔', '次元断层', '深渊讨伐战', '赛马', +]; + +function categorySortIndex(c: string): number { + const i = CATEGORY_ORDER.indexOf(c); + return i === -1 ? CATEGORY_ORDER.length : i; +} + +/** 纯噪声条目(用户裁决不列):玩家经验值加成、公会战排名公示 */ +function isNoiseEntry(e: ScheduleEntry): boolean { + if (e.category === '公会战排名公示') return true; + return /玩家经验值/.test(e.description); +} + +/** YYYY/MM/DD → MM/DD(面板展示只到月日) */ +function shortDate(d: string): string { + const m = /^\d{4}\/(\d{2}\/\d{2})$/.exec(d); + return m ? m[1] : d; +} + +/** 拉取日程(后端未部署 /schedule 时 fetch 404 → 抛错由调用方静默) */ +/** 后端条目归一化:女神祭从「活动」拆为独立类别;丢弃纯噪声(玩家经验值加成/公会战排名公示——类别也不出现在面板);fes 扭蛋折叠 */ +function normalizeEntry(e: ScheduleEntry): ScheduleEntry | null { + if (isNoiseEntry(e)) return null; + // 类别删除(用户裁决):斗技场/登录奖励不再出现 + if (e.category === '斗技场' || e.category === '登录奖励') return null; + // 驾车游并入季卡(用户裁决:季卡与驾车游是同一个东西) + if (e.category === '季卡' || e.category === '驾车游') { + return { ...e, category: '季卡驾车游' }; + } + if (e.category === '活动' && /女神祭/.test(e.description)) { + return { ...e, category: '女神祭' }; + } + // 扭蛋名单折叠(用户裁决):fes| 前缀(gacha_name 含 フェス/FES 的池)→ up 首人 fes扭蛋; + // 普通池 up 名单 → 只显前两名,其余计「……等N人」 + if (e.category === '扭蛋' && e.description.startsWith('up ')) { + const names = e.description.slice(3).split(',').map((s) => s.trim()).filter(Boolean); + if (names.length > 2) { + return { ...e, description: `up ${names[0]},${names[1]}……等${names.length}人` }; + } + return e; + } + if (e.category === '扭蛋' && e.description.startsWith('fes|')) { + const body = e.description.slice(4); + if (body.startsWith('up ')) { + const names = body.slice(3).split(',').map((s) => s.trim()).filter(Boolean); + if (names.length > 0) { + return { ...e, description: `up ${names[0]} fes扭蛋` }; + } + } + // exchange_id=0 的池(description=官方池名):剥掉标记只显池名 + return { ...e, description: body }; + } + return e; +} + +async function fetchSchedule(): Promise { + const res = await API.get('/schedule'); + const raw = Array.isArray(res?.data) ? res.data : []; + return raw.map(normalizeEntry).filter((e): e is ScheduleEntry => e !== null); +} + +/** + * 面板滚动无缝接力(用户手感要求):内层滚到顶/底后,滚轮剩余滚动量立即转嫁给外层滚动容器, + * 不经过浏览器默认的链式滚动(默认实现会先顿一拍再链出)。 + * 内层还能滚时完全交给浏览器,不做任何干预。 + * React 的 onWheel 是被动监听,preventDefault 无效,故用 ref + 原生非被动监听。 + * 转嫁目标:从面板 Body 沿祖先向上找第一个「可滚动」(scrollHeight > clientHeight)的容器; + * daily 布局的页面滚动发生在 内容区,不是 window。 + */ +function useSeamlessScrollRelay() { + // 必须用回调 ref 而非 [] 依赖 effect:Popover lazyMount 下 Body 首次打开才挂载, + // effect 首跑时 ref 是 null 且 ref 赋值不触发重跑,监听器会永远注册不上 + const cleanupRef = React.useRef<(() => void) | null>(null); + const scrollBodyRef = React.useCallback((el: HTMLDivElement | null) => { + cleanupRef.current?.(); + cleanupRef.current = null; + if (!el) return; + const findOuterScroller = (): HTMLElement | null => { + let p = el.parentElement; + while (p) { + if (p.scrollHeight > p.clientHeight) { + const oy = getComputedStyle(p).overflowY; + if (oy === 'auto' || oy === 'scroll') return p; + } + p = p.parentElement; + } + return document.scrollingElement as HTMLElement | null; + }; + const onWheel = (e: WheelEvent): void => { + const delta = e.deltaY; + if (delta === 0) return; + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { + e.preventDefault(); + const outer = findOuterScroller(); + if (outer) outer.scrollBy({ top: delta, behavior: 'auto' }); + } + }; + el.addEventListener('wheel', onWheel, { passive: false }); + cleanupRef.current = () => el.removeEventListener('wheel', onWheel); + }, []); + React.useEffect(() => () => cleanupRef.current?.(), []); + return scrollBodyRef; +} + +/* ==================== 全局 watcher(挂 _sidebar 布局层) ==================== */ + +let scheduleCache: ScheduleEntry[] | null = null; +let scheduleCacheDay = ''; +let scheduleFetching = false; + +/** 勾选类别 × 今天的开启条目 → 浏览器通知。仅当日已到提醒时刻才检查;每 key 只提醒一次 */ +function notifyTodaysStarts(entries: ScheduleEntry[], prefs: ScheduleNotifyPrefs): void { + // 提醒时刻闸:未到用户设置的 HH:mm 不检查(否则打开页面即弹,时刻设置形同虚设) + const now = new Date(); + const hm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + if (hm < prefs.notifyTime) return; + const today = todayStr(); + const targets = entries.filter((e) => prefs.categories.includes(e.category) && e.start_time === today); + if (targets.length === 0) return; + const notified = loadNotified(); + // 旧记录清理:只保留 90 天内的记录(值=标记日),防无限膨胀 + const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000; + for (const k of Object.keys(notified)) { + const ts = Number((notified[k] as string).split('|')[1] ?? 0); + if (ts < cutoff) delete notified[k]; + } + // 记录值形如 "notified|"(老数据可能裸 "notified"),必须按前缀判,严格等值会把已通知的当新条目重复弹 + const fresh = targets.filter((e) => !(notified[e.key] ?? '').startsWith('notified')); + if (fresh.length === 0) return; + fresh.forEach((e) => { notified[e.key] = `notified|${Date.now()}`; }); + saveNotified(notified); + const body = fresh.map((e) => `${e.category}:${e.description}`).join('\n'); + try { + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + new Notification('AutoPCR 今日开启', { body }); + } else { + // 通知 API 未授权:不强弹,控制台留痕即可(勾选类别在菜单常驻可见) + console.info('[AutoPCR 今日开启]', body); + } + } catch { + console.info('[AutoPCR 今日开启]', body); + } +} + +export function ScheduleNotifyWatcher(): null { + useEffect(() => { + let cancelled = false; + const tick = async () => { + const prefs = loadSchedulePrefs(); + if (!prefs.enabled || scheduleFetching) return; + const today = todayStr(); + // 刷新策略:缓存是今天的且没有「已结束但仍在缓存里」的勾选条目 → 不拉(活动结束时才更新一次) + if (scheduleCache && scheduleCacheDay === today) { + const stale = scheduleCache.some((e) => prefs.categories.includes(e.category) && !isNoiseEntry(e) && e.end_time < today); + if (!stale) { + notifyTodaysStarts(scheduleCache, prefs); + return; + } + } + scheduleFetching = true; + try { + const entries = await fetchSchedule(); + if (cancelled) return; + scheduleCache = entries; + scheduleCacheDay = today; + notifyTodaysStarts(entries, prefs); + } catch { + // 后端未部署 /schedule 或网络失败:静默,下个 tick 再试 + } finally { + scheduleFetching = false; + } + }; + void tick(); + // 低频巡检:每 10 分钟看一眼是否到了提醒时刻/是否跨天;拉取受上面的节流约束,实际网络请求每天至多一次 + const timer = window.setInterval(() => void tick(), 10 * 60 * 1000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + return null; +} + +/* ==================== 下拉菜单(设置 + 正在进行常驻) ==================== */ + +export function ScheduleNotifySettings() { + const [prefs, setPrefs] = useState(() => loadSchedulePrefs()); + const [entries, setEntries] = useState(() => scheduleCache ?? []); + const [loadFailed, setLoadFailed] = useState(false); + const scrollBodyRef = useSeamlessScrollRelay(); + + useEffect(() => { + saveSchedulePrefs(prefs); + }, [prefs]); + + // 打开面板时刷新「正在进行」列表:缓存优先,没有再拉 + const refresh = React.useCallback(async () => { + if (scheduleCache) { + setEntries(scheduleCache); + return; + } + try { + const list = await fetchSchedule(); + scheduleCache = list; + scheduleCacheDay = todayStr(); + setEntries(list); + setLoadFailed(false); + } catch { + setLoadFailed(true); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const today = todayStr(); + /** 勾选类别中当前开放的条目(start<=today<=end),按类别分组 */ + const ongoing = useMemo(() => { + // 展示口径(用户裁决):结束日期晚于今天即列出;纯噪声(玩家经验值加成/公会战排名公示)不列 + const chosen = entries.filter((e) => + prefs.categories.includes(e.category) + && e.end_time > today + && !isNoiseEntry(e), + ); + const byCat = new Map(); + for (const e of chosen) { + const list = byCat.get(e.category) ?? []; + list.push(e); + byCat.set(e.category, list); + } + return Array.from(byCat.entries()).sort((a, b) => categorySortIndex(a[0]) - categorySortIndex(b[0])); + }, [entries, prefs.categories, today]); + + const allCategories = useMemo(() => [...CATEGORY_ORDER], []); + + return ( + + + + + + 日程通知 + + + + + + + + {/* 进行中(勾选类别)常驻区——唯一滚动区 */} + + 正在进行 + {ongoing.length === 0 ? ( + + {loadFailed ? '日程获取失败(后端未部署 /schedule?)' : prefs.categories.length === 0 ? '下方勾选类别后,这里显示进行中的日程' : '勾选类别暂无进行中日程'} + + ) : ( + + {ongoing.map(([cat, list]) => ( + + {cat} + {list.map((e) => ( + + {e.description} {shortDate(e.start_time)} - {shortDate(e.end_time)} + + ))} + + ))} + + )} + + + + {/* 固定底栏:通知开关/提醒时刻/类别勾选(不随上进行中列表滚动) */} + + + {/* 通知开关 + 提醒时刻 */} + + { + const next = !!details.checked; + if (next && typeof Notification !== 'undefined' && Notification.permission === 'default') { + try { + await Notification.requestPermission(); + } catch { + // 未授权期间仅面板常驻可见 + } + } + setPrefs((prev) => ({ ...prev, enabled: next })); + }} + colorPalette="orange" + size="md" + > + 开启通知 + + + 提醒时刻 + { + const v = ev.target.value; + if (/^\d{2}:\d{2}$/.test(v)) setPrefs((prev) => ({ ...prev, notifyTime: v })); + }} + style={{ + background: 'transparent', + border: '1px solid currentColor', + borderRadius: 4, + color: 'inherit', + fontSize: '12px', + padding: '1px 4px', + }} + /> + + + {/* 类别勾选 */} + + 通知类别 + {allCategories.length === 0 ? ( + {loadFailed ? '日程获取失败' : '暂无日程数据'} + ) : ( + + {allCategories.map((cat) => ( + { + const on = !!details.checked; + setPrefs((prev) => ({ + ...prev, + categories: on ? [...prev.categories, cat] : prev.categories.filter((c) => c !== cat), + })); + }} + colorPalette="orange" + size="md" + > + {cat} + + ))} + + )} + + {loadFailed && ( + + )} + + + + + + ); +} diff --git a/src/components/Account/Toc.tsx b/src/components/Account/Toc.tsx index 78ade5b..8995367 100644 --- a/src/components/Account/Toc.tsx +++ b/src/components/Account/Toc.tsx @@ -9,9 +9,10 @@ import { TocItem } from './Area' interface SidebarProps extends BoxProps { tocList: TocItem[] + onNavigate?: () => void } -export default function Toc({ tocList, ...rest }: SidebarProps) { +export default function Toc({ tocList, onNavigate, ...rest }: SidebarProps) { return ( { return ( - + {item.name} ) @@ -35,8 +36,9 @@ export default function Toc({ tocList, ...rest }: SidebarProps) { interface NavItemProps extends FlexProps { module_id: string + onNavigate?: () => void } -const NavItem = ({ module_id, children, ...rest }: NavItemProps) => { +const NavItem = ({ module_id, onNavigate, children, ...rest }: NavItemProps) => { return ( { document.querySelector(`#${module_id}`)?.scrollIntoView({ behavior: "smooth" }); + onNavigate?.(); }}> void | Promise> = new Map(); + +/** 全局忙碌表(模块级真源):互斥判定读它;UI 转圈由各页面本地状态自理(页面卸载即动作作废,无跨页同步) */ +export const busyAccountsRef = new Set(); + +/** busy 变更唯一入口:改表。UI 不订阅(还原旧貌:跨页不联动) */ +export function patchBusy(alias: string, busy: boolean): void { + if (busy) busyAccountsRef.add(alias); + else busyAccountsRef.delete(alias); +} + +export const DISPLAY_NAME_KEY = (alias: string) => 'autopcr_displayName_' + alias; + +export function getDisplayName(alias: string): string { + return safeGetItem(DISPLAY_NAME_KEY(alias)) || alias; +} + +/** 每账号"弹结果"标记:该账号执行完自动弹出结果窗 */ +export const POPUP_FLAG_KEY = (alias: string) => 'autopcr_popupResult_' + alias; + +export function loadPopupFlag(alias: string): boolean { + // 默认勾选:只有明确存过 'false'(用户关掉过)才静默 + return safeGetItem(POPUP_FLAG_KEY(alias)) !== 'false'; +} + +/** 「弹结果」总开关(工具栏勾选框),默认开 */ +export const POPUP_MASTER_KEY = 'autopcr_popupResult'; + +/** 主页账号视图模式(表格/卡片) */ +export const VIEW_MODE_KEY = 'accountViewMode'; + +/** 账号收藏(区服→功能名列表)存储键 */ +export const favKey = (alias: string) => `autopcr_fav_${alias}`; + +export function loadPopupMaster(): boolean { + return safeGetItem(POPUP_MASTER_KEY) !== 'false'; +} + +/** 账号日常执行完成事件(无条件派发,监听端按通知开关过滤),载荷=账号名 */ +export function emitDailyFinished(alias: string): void { + try { + window.dispatchEvent(new CustomEvent('autopcr_daily_finished', { detail: alias })); + } catch { + // 环境不支持 CustomEvent 时忽略 + } +} + +/** 订阅账号日常执行完成事件;返回取消订阅函数 */ +export function onDailyFinished(cb: (alias: string) => void): () => void { + const listener = (e: Event): void => { + const detail = (e as CustomEvent).detail as string | undefined; + if (detail) cb(detail); + }; + window.addEventListener('autopcr_daily_finished', listener); + return () => { + window.removeEventListener('autopcr_daily_finished', listener); + }; +} + +/** 文字越多两侧越窄;3 个字以内保持默认内边距(图标/短按钮保持好点) */ +export function textFitPadding(label: string): string | undefined { + const len = Array.from(label).length; + if (len <= 3) return undefined; + if (len <= 5) return '0.5rem'; + return '0.25rem'; +} + +/** 默认账号名单,带版本号存本地 */ +const BATCH_KEY = 'autopcr_batch_v1'; + +export function loadBatch(): string[] { + try { + const raw = safeGetItem(BATCH_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : null; + const list = (parsed as { accounts?: unknown } | null)?.accounts; + if (Array.isArray(list)) { + return list.filter((x): x is string => typeof x === 'string'); + } + } catch { + return []; + } + return []; +} + +export function saveBatch(accounts: string[]): void { + try { + localStorage.setItem(BATCH_KEY, JSON.stringify({ version: 1, accounts })); + } catch { + // 本地存储不可用则仅本次会话有效 + } +} + +/** localStorage 读兜底:隐私模式/禁 cookie 场景访问 localStorage 即抛 SecurityError,读函数必须吞掉 */ +export function safeGetItem(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** localStorage 删除兜底:与 safeSetItem 同语义 */ +export function safeRemoveItem(key: string): void { + try { + localStorage.removeItem(key); + } catch { + // 本地存储不可用则忽略 + } +} +export function safeSetItem(key: string, value: string): boolean { + try { + localStorage.setItem(key, value); + return true; + } catch { + // 本地存储不可用:返回 false 由调用方决定是否提示 + return false; + } +} + +/** 危险分区的显示名(后端约定):快捷按钮 dangerous 标记、危险确认弹窗、Picker 染色共用此常量 */ +export const DANGEROUS_AREA_NAME = '危险'; + +/** 批量运行虚拟账号名(后端约定):全选/全体兜底/同步目标列表等处统一排除,单勾直打后端会 404 */ +export const BATCH_RUNNER = 'BATCH_RUNNER'; + +/** 安全解析后端错误文案,避免 Blob/.text 抛错或 [object Object](自 Config.tsx 迁入,通用工具) */ +export async function getErrorDescription(err: unknown, fallback = '网络错误'): Promise { + const data = (err as { response?: { data?: unknown } })?.response?.data; + try { + if (data == null) { + if (err instanceof Error && err.message) return err.message; + return fallback; + } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + const txt = await data.text(); + return txt || fallback; + } + if (typeof data === 'string') return data || fallback; + if (typeof data === 'object') { + try { + return JSON.stringify(data); + } catch { + return fallback; + } + } + return String(data); + } catch { + return fallback; + } +} + + +/** 校验配置项:按 schema 类型严格校验值合法性(bool 只收 boolean;time 必须 hh:mm;multi 候选过滤),不合法返回 undefined(自 AccountCard/ConfigImportExport 双胞胎收簸,取严格语义) */ +export function toCheckedConfigItem( + type: ConfigType, + candidates: Candidate[], + value: unknown, +): ConfigValue | undefined { + switch (type) { + case 'bool': + if (typeof value === 'boolean') return value; + break; + case 'single': + if (typeof value === 'string' || typeof value === 'number') return value; + break; + case 'int': + // 只收整数:小数对 int 型配置无意义(后端解析行为不明,宁拒不猜) + if (typeof value === 'number' && Number.isInteger(value)) return value; + break; + case 'text': + if (typeof value === 'string') return value; + break; + case 'time': + // hh:mm 且范围合法(00-23:00-59):"99:99" 这种格式对但值错的拒绝 + if ( + typeof value === 'string' && + /^\d{2}:\d{2}$/.test(value) && + Number(value.slice(0, 2)) <= 23 && + Number(value.slice(3)) <= 59 + ) + return value; + break; + case 'multi': + case 'multi_search': { + if (!Array.isArray(value)) break; + const checkedArray: (string | number)[] = []; + for (const item of value) { + if (typeof item !== 'number' && typeof item !== 'string') continue; + if (candidates.find((v) => item === v.value)) checkedArray.push(item); + } + return checkedArray; + } + } + return undefined; +} + +/** 按模块 schema 过滤导入配置:只收 schema 内的键,逐项过 toCheckedConfigItem 校验 */ +export function realImportByModule( + module: ModuleResponse, + configs: Record, +): { accepted: Record; schemaKeys: Set } { + const accepted: Record = {}; + const schemaKeys = new Set(); // schema 实际接管的键:含被校验拒绝的(拒绝值不得经补充键复活) + for (const moduleKey in module.info) { + schemaKeys.add(moduleKey); // 开关键也属 schema:布尔特判拒掉的字符串 "true" 不再从补充键溜回来 + if (configs[moduleKey] !== undefined && typeof configs[moduleKey] === 'boolean') { + accepted[moduleKey] = configs[moduleKey]; + } + const moduleConf = module.info[moduleKey].config; + for (const moduleConfKey in moduleConf) { + schemaKeys.add(moduleConfKey); + const moduleItem = moduleConf[moduleConfKey]; + const confItem = toCheckedConfigItem(moduleItem.config_type, moduleItem.candidates, configs[moduleConfKey]); + if (confItem !== undefined) { + accepted[moduleConfKey] = confItem; + } + } + } + return { accepted, schemaKeys }; +} + +/** 配置文件导入共享流程:解析 base64 → 逐区服 schema 校验 → PUT → 成功后写收藏标记。 + * 返回值区分三种结果:成功对象 / 抛出可展示错误。收藏写失败不抛(降级,由 favWriteFailed 通知调用方) */ +export async function importConfigFile(opts: { + alias: string; + rawCfg: string; + /** 区服名单来源:调用方各自获取(卡片现查 / 弹窗用已有 props) */ + areas: { key: string }[]; + /** 收藏写失败时回调(用于降级提示);不传则静默 */ + onFavWriteFailed?: () => void; +}): Promise { + const { alias, rawCfg, areas, onFavWriteFailed } = opts; + let configs: Record>; + try { + const parsed: unknown = JSON.parse(decodeURIComponent(atob(rawCfg.trim()))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('bad'); + } + configs = parsed as Record>; + } catch { + throw new Error('配置文件格式无效,请检查选取的配置文件。'); + } + + const configItems = await Promise.all(areas.map((area) => getAccountConfig(alias, area.key))); + const uploadConfig: Record = {}; + const importedFav: Record = {}; + + // 阶段一:全区 schema 校验 + 收集 schema 接管键的「全区并集」。 + // 并集是必须的:uploadConfig 跨区扁平累积,若两区 schema 不一致,A 区被拒的 schema 键 + // 可能在阶段二被当成「补充键」经 B 区的局部 schemaKeys 放行(审计十 #11) + const allSchemaKeys = new Set(); + const areaAccepted: { areaKey: string; accepted: Record }[] = []; + configItems.forEach((value, index) => { + const areaKey = areas[index].key; + const areaConfig = configs[areaKey]; + // 区服层形状守卫:字符串/数组是真值,直接迭代会把 "abc" 拆成 "0"/"1"/"2" 垃圾键直通 PUT + if (typeof areaConfig !== 'object' || areaConfig === null || Array.isArray(areaConfig)) return; + + const { accepted, schemaKeys } = realImportByModule(value, areaConfig); + areaAccepted.push({ areaKey, accepted }); + Object.assign(uploadConfig, accepted); + schemaKeys.forEach((k) => allSchemaKeys.add(k)); + }); + + // 阶段二:收藏标记 + schema 外补充键(用全区并集判定)。被校验拒绝的非法值 + // ("99:99"、1.5、字符串 "true")在任何区都不得经补充键复活直通 PUT + areas.forEach((area) => { + const areaKey = area.key; + const areaConfig = configs[areaKey]; + if (typeof areaConfig !== 'object' || areaConfig === null || Array.isArray(areaConfig)) return; + + for (const key in areaConfig) { + if (key.startsWith('_fav_')) { + importedFav[areaKey] = importedFav[areaKey] || []; + if (areaConfig[key] === true) { + importedFav[areaKey].push(key.slice(5)); + } + } else if (!allSchemaKeys.has(key) && uploadConfig[key] === undefined && areaConfig[key] !== undefined) { + const v = areaConfig[key]; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + uploadConfig[key] = v; + } + } + } + }); + + if (Object.keys(uploadConfig).length === 0) { + throw new Error('文件中没有可用配置,未做任何修改。'); + } + // 全部成功后才写收藏(PUT 失败不覆盖现有收藏);文件不含 _fav_(旧版导出)时不动收藏。 + // 收藏按「文件里出现的区服」合并写回:文件没覆盖的区服(如文件是别的号导出的)保留既有收藏,不整表替换 + await putAccountConfigs(alias, uploadConfig); + if (Object.keys(importedFav).length > 0) { + try { + const raw = localStorage.getItem(favKey(alias)); + const existing = raw ? (JSON.parse(raw) as Record) : {}; + localStorage.setItem(favKey(alias), JSON.stringify({ ...existing, ...importedFav })); + } catch { + onFavWriteFailed?.(); + } + } +} + diff --git a/src/components/Account/quickActions.ts b/src/components/Account/quickActions.ts new file mode 100644 index 0000000..06eddcf --- /dev/null +++ b/src/components/Account/quickActions.ts @@ -0,0 +1,50 @@ +/** 主页工具栏自定义功能按钮:本地存储(与收藏星标同等待遇) */ + +export interface QuickActionItem { + /** 功能的模块 key(唯一标识,后端改名不影响) */ + key: string; + /** 显示名(每次以服务器实时数据为准,这里只是缓存) */ + name: string; + areaKey: string; + areaName: string; + /** 危险分区的功能,执行前要确认 */ + dangerous: boolean; +} + +const KEY = 'autopcr_quickActions_v1'; +const VERSION = 1; + +/** 读取:损坏/被清一律按空列表处理,绝不抛错 */ +export function loadQuickActions(): QuickActionItem[] { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as { version?: number; buttons?: unknown }; + if (parsed?.version !== VERSION || !Array.isArray(parsed.buttons)) return []; + return parsed.buttons + .filter( + (b): b is QuickActionItem => + !!b && typeof (b as QuickActionItem).key === 'string' && typeof (b as QuickActionItem).name === 'string', + ) + .map((b) => ({ + // 只取已知字段:未知属性不透传(类型谓词只验了 key/name,展开会把垃圾字段带回) + key: b.key, + name: b.name, + areaKey: typeof b.areaKey === 'string' ? b.areaKey : '', + areaName: typeof b.areaName === 'string' ? b.areaName : '', + dangerous: b.dangerous === true, // 只认显式 true(旧缓存字段缺失不误判危险) + })); + } catch { + return []; + } +} + +/** 写入:失败(隐私模式/已满)返回 false,由调用方提示 */ +export function saveQuickActions(items: QuickActionItem[]): boolean { + try { + localStorage.setItem(KEY, JSON.stringify({ version: VERSION, buttons: items })); + return true; + } catch { + return false; + } +} diff --git a/src/components/SideBar/Index.tsx b/src/components/SideBar/Index.tsx index 7884e55..3c48355 100644 --- a/src/components/SideBar/Index.tsx +++ b/src/components/SideBar/Index.tsx @@ -30,6 +30,7 @@ import { toaster } from '../../components/ui/toaster' import {useEffect} from 'react' import {useUserRole} from "@api/Account.ts"; import RunningStatus from '../Account/RunningStatus'; +import { ScheduleNotifyWatcher } from '../Account/ScheduleNotify'; interface NavItemProps extends FlexProps { icon?: IconType @@ -169,6 +170,7 @@ export default function Nav() { + diff --git a/src/components/count.tsx b/src/components/count.tsx deleted file mode 100644 index 0001580..0000000 --- a/src/components/count.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useState } from "react"; - -export function useCountHook(val = 0) { - const [state, setState] = useState(val); - - const inc = () => setState((state) => state + 1); - const dec = () => setState((state) => state - 1); - - return [state, inc, dec] as const; -} diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 8f21b5f..5d92ce9 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -31,6 +31,7 @@ export interface AccountResponse { * 批量所有账号 */ all_accounts: string[]; + daily_clean_time?: { status?: string } | null; } export interface AreaInfo { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 08da434..f58798f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as DailyIndexImport } from './routes/daily/index' import { Route as DailyValidateImport } from './routes/daily/validate' import { Route as DailyLoginImport } from './routes/daily/login' import { Route as DailySidebarImport } from './routes/daily/_sidebar' @@ -31,6 +32,11 @@ const DailyRoute = DailyImport.update({ getParentRoute: () => rootRoute, } as any) +const DailyIndexRoute = DailyIndexImport.update({ + path: '/', + getParentRoute: () => DailyRoute, +} as any) + const DailyValidateRoute = DailyValidateImport.update({ path: '/validate', getParentRoute: () => DailyRoute, @@ -83,6 +89,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DailyValidateImport parentRoute: typeof DailyImport } + '/daily/': { + preLoaderRoute: typeof DailyIndexImport + parentRoute: typeof DailyImport + } '/daily/_sidebar/account/$account': { preLoaderRoute: typeof DailySidebarAccountAccountImport parentRoute: typeof DailySidebarImport @@ -109,6 +119,7 @@ export const routeTree = rootRoute.addChildren([ ]), DailyLoginRoute, DailyValidateRoute, + DailyIndexRoute, ]), ]) diff --git a/src/routes/daily/_sidebar/account/$account.tsx b/src/routes/daily/_sidebar/account/$account.tsx index d52baca..2ea3b92 100644 --- a/src/routes/daily/_sidebar/account/$account.tsx +++ b/src/routes/daily/_sidebar/account/$account.tsx @@ -1,14 +1,19 @@ import { Box, Button, HStack, Tabs, Tag } from '@chakra-ui/react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { FiActivity, FiCheck, FiStar, FiTarget, FiUserX } from 'react-icons/fi'; +import NiceModal from '@ebay/nice-modal-react'; import { AccountResponse } from '@interfaces/Account'; import Area, { clearAreaConfigCache } from '@components/Account/Area'; import ConfigImportExport from '@components/Account/ConfigImportExport.tsx'; import Info from '@components/Account/Info'; import { createFileRoute } from '@tanstack/react-router'; -import { getAccount, postAccountAreaDaily } from '@api/Account'; +import { getAccount, getAccountDailyResultList, postAccountAreaDaily } from '@api/Account'; +import { POPUP_FLAG_KEY, loadPopupFlag, emitDailyFinished, textFitPadding, safeSetItem, getDisplayName, busyAccountsRef, patchBusy } from '@components/Account/accountShared'; +import { getErrorDescription } from '@components/Account/Config'; import { toaster } from '../../../../components/ui/toaster'; +import { Checkbox } from '../../../../components/ui/checkbox'; +import ResultInfoModal from '@components/Account/ResultInfoModal'; export const Route = createFileRoute('/daily/_sidebar/account/$account')({ component: AccountComponent, @@ -21,21 +26,30 @@ function AccountComponent() { const initialAccountInfo = Route.useLoaderData(); const [accountInfo, setAccountInfo] = useState(initialAccountInfo); - const initialTab = - initialAccountInfo?.username !== '' && initialAccountInfo?.password !== '' ? '1' : '0'; + const hasCredentials = + (initialAccountInfo?.username ?? '') !== '' && (initialAccountInfo?.password ?? '') !== ''; + const hasAreas = (initialAccountInfo?.area?.length ?? 0) > 0; + const initialTab = hasCredentials && hasAreas ? '1' : '0'; const [activeTab, setActiveTab] = useState(initialTab); const [favOnlyMap, setFavOnlyMap] = useState>({}); const [cleanLoading, setCleanLoading] = useState(false); const [cleanStatus, setCleanStatus] = useState( - () => (initialAccountInfo as any)?.daily_clean_time?.status || '', + () => initialAccountInfo?.daily_clean_time?.status || '', ); const [displayName, setDisplayName] = useState( - () => localStorage.getItem(`autopcr_displayName_${account}`) || account, + () => getDisplayName(account), ); + // 「弹结果」:本账号执行完自动弹出结果窗(本地存储,按账号记忆) + const [popupOn, setPopupOn] = useState(() => loadPopupFlag(account)); + + // 跨账号晚到写守卫:async 链 await 后校验请求发起时的账号是否还是当前账号(换号不重挂载,晚到的 A 响应不得写进 B 页) + const accountRef = useRef(account); + accountRef.current = account; + const statusMeta = useMemo(() => { if (cleanStatus === '成功' || cleanStatus === '跳过') { return { @@ -57,11 +71,14 @@ function AccountComponent() { }, [cleanStatus]); const refreshAccountData = async () => { + const a = accountRef.current; try { - const freshData = await getAccount(account); + const freshData = await getAccount(a); + if (accountRef.current !== a) return; // 换号了:晚到数据丢弃 setAccountInfo(freshData); - setDisplayName(localStorage.getItem(`autopcr_displayName_${account}`) || account); - const st = (freshData as any)?.daily_clean_time?.status; + setDisplayName(getDisplayName(a)); + // 直接写状态:initialAccountInfo 是 loader 快照(不随本页刷新变化),effect 收不到 + const st = freshData?.daily_clean_time?.status; if (st) setCleanStatus(st); } catch (e) { console.error(e); @@ -73,18 +90,24 @@ function AccountComponent() { await refreshAccountData(); }; - // 仅切换账号时重置 Tab;刷新 accountInfo 不要把用户正在看的 Tab 打回初始 + // 切换账号(含浏览器后退/前进)时整体重置;刷新 accountInfo 不要把用户正在看的 Tab 打回初始 useEffect(() => { + setAccountInfo(initialAccountInfo); setActiveTab(initialTab); - setDisplayName(localStorage.getItem(`autopcr_displayName_${account}`) || account); - const st = (initialAccountInfo as any)?.daily_clean_time?.status; - if (st) setCleanStatus(st); + setDisplayName(getDisplayName(account)); + setCleanStatus(initialAccountInfo?.daily_clean_time?.status || ''); + setFavOnlyMap({}); + setCleanLoading(false); // 上一账号清理在途时切过来:B 页按钮不得转圈 + setPopupOn(loadPopupFlag(account)); // 换号跟勾:弹结果是每账号标记,重置 effect 不补这条会显示上个号的开关态 // eslint-disable-next-line react-hooks/exhaustive-deps }, [account]); + // 数据跟进 effect:initialAccountInfo 变化(父级重拉、路由重载)时跟进状态与显示名。 + // 换号时本 effect 与上面的重置 effect 都会跑,声明序保证重置先行、本 effect 收尾——语义正确非打架: + // 换号=重置 effect 清场 → 本 effect 写入新号数据;本页刷新=只有本 effect 跑(不打回用户正看的 tab) useEffect(() => { - setDisplayName(localStorage.getItem(`autopcr_displayName_${account}`) || account); - const st = (initialAccountInfo as any)?.daily_clean_time?.status; + setDisplayName(getDisplayName(account)); + const st = initialAccountInfo?.daily_clean_time?.status; if (st) setCleanStatus(st); }, [initialAccountInfo, account]); @@ -109,24 +132,27 @@ function AccountComponent() { const handleCleanDaily = async () => { const a = accountInfo?.alias || account; - const nameForUi = - localStorage.getItem(`autopcr_displayName_${a}`) || displayName || a; + const nameForUi = getDisplayName(a); + // 忙碌互斥第六条路径:详情页清理与卡片清理是同一个长任务接口,双向都要设防—— + // 主页批量/同步把该账号当目标、或该账号已在主页执行中,这里在途时另一侧必须被拒(点击时点互斥)。 + // 守卫放在置 loading 前:拒绝路径不碰页面状态 + if (busyAccountsRef.has(a)) { + toaster.create({ type: 'warning', title: `${nameForUi}正在执行中`, description: '请等待当前操作完成' }); + return; + } setCleanLoading(true); setCleanStatus(''); + patchBusy(a, true); toaster.create({ type: 'info', title: `开始为${nameForUi}清理日常...` }); try { const res = await postAccountAreaDaily(a); - const st = - (res as any)?.daily_clean_time?.status || - (res as any)?.status || - ''; - - setCleanStatus(st); + void emitDailyFinished(a); + // 以下三个都是「操作结果告知/全局标记」:与用户切到哪无关,不因切号丢失(与失败 toast 口径对称) sessionStorage.setItem('autopcr_need_refresh_dashboard', '1'); - await refreshAccountData(); - + const resAny = res as { daily_clean_time?: { status?: string } | null; status?: string } | undefined; + const st = resAny?.daily_clean_time?.status || resAny?.status || ''; if (st === '错误') { toaster.create({ type: 'error', title: `${nameForUi}清日常结束` }); } else if (st === '警告' || st === '中止') { @@ -134,15 +160,36 @@ function AccountComponent() { } else { toaster.create({ type: 'success', title: `${nameForUi}清日常成功` }); } + if (accountRef.current !== a) return; // 换号了:晚到结果不写新页面(loading 由重置 effect 兜底) + + setCleanStatus(st); + await refreshAccountData(); + + // 完成时刻现读存储(与 Module/DashBoard 姊妹路径同语义):勾选在长任务在途期间可交互,点击时快照会违背用户刚表达的意愿 + if (loadPopupFlag(a) && accountRef.current === a) { // 换号后不弹:A 的结果窗不盖在 B 页上(审计六 P2-1) + getAccountDailyResultList(a) + .then((resList) => { + if (accountRef.current !== a) return; // 结果列表在途时切号:同样丢弃 + // modal 关闭路径的 reject 不是拉取失败,单独吞掉 + void NiceModal.show(ResultInfoModal, { alias: a, title: '日常', resultInfo: resList }).catch(() => {}); + }) + .catch(() => { + if (accountRef.current !== a) return; + toaster.create({ type: 'warning', title: '结果获取失败', description: '无法拉取本次日常结果' }); + }); + } } catch (err: any) { - setCleanStatus('错误'); + if (accountRef.current === a) setCleanStatus('错误'); toaster.create({ type: 'error', title: `${nameForUi}清日常失败`, - description: (err?.response?.data as string) || err?.message || '网络错误', + description: await getErrorDescription(err), }); } finally { - setCleanLoading(false); + // 换号后 loading 交给重置 effect,这里不再碰(晚到 finally 会把 B 页的按钮状态搅乱) + if (accountRef.current === a) setCleanLoading(false); + // 本笔是该账号忙碌登记的唯一作者(互斥网保证执行期间无并发路径),直接清 + patchBusy(a, false); } }; @@ -206,12 +253,13 @@ function AccountComponent() { {activeTab !== '0' && ( + + { + const next = !!details.checked; + setPopupOn(next); + if (!safeSetItem(POPUP_FLAG_KEY(account), next ? 'true' : 'false')) { + // 静默吞掉的话勾选不保留且无任何提示(与改名保存失败同等待遇) + toaster.create({ type: 'warning', title: '弹结果设置保存失败', description: '本地存储不可用,本次修改不会保留' }); + } + }} + colorPalette="blue" + size="md" + title="开启后,本账号清理完日常自动弹出结果窗" + > + 弹结果 + {statusMeta && ( {statusMeta.icon} diff --git a/src/routes/daily/index.tsx b/src/routes/daily/index.tsx new file mode 100644 index 0000000..c2c15d8 --- /dev/null +++ b/src/routes/daily/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { Route as AccountRoute } from '@routes/daily/_sidebar/account/index' + +// 打开 /daily/ 根路径时自动带到账号主页; +// 未登录时账号主页的请求会 401,全局拦截器会自动送去登录页。 +export const Route = createFileRoute('/daily/')({ + beforeLoad: () => { + throw redirect({ to: AccountRoute.to }) + }, +}) diff --git a/src/routes/daily/login.tsx b/src/routes/daily/login.tsx index 845e495..9c10f59 100644 --- a/src/routes/daily/login.tsx +++ b/src/routes/daily/login.tsx @@ -11,12 +11,28 @@ import { LuMoon, LuSun } from 'react-icons/lu' import LoginWithPasswordComponent from "@components/Login/LoginWithPasswordComponent" import { Skeleton } from '../../components/ui/skeleton' import autopcr from "@/assets/autopcr.svg" -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, isRedirect, redirect } from '@tanstack/react-router' import { keyframes } from '@emotion/react' import { useColorMode } from '../../components/ui/color-mode' +import { API } from '@api/APIUtils' +import axios from 'axios' +import { Route as AccountRoute } from '@routes/daily/_sidebar/account/index' export const Route = createFileRoute('/daily/login')({ component: LoginComponent, + // 已登录时登录页没有意义,直接送去账号主页 + beforeLoad: async () => { + try { + await API.get('/account', { skipErrorHandler: true, skipAuthRedirect: true }) + throw redirect({ to: AccountRoute.to }) + } catch (e) { + if (isRedirect(e)) throw e + // 401/未授权:确实是未登录,留在登录页 + if (axios.isAxiosError(e) && e.response?.status === 401) return + // 网络故障等非授权错误:放行进入应用,让页面自身的 401 拦截器兜底,避免弱网被静默扣在登录页 + throw redirect({ to: AccountRoute.to }) + } + }, }) const float = keyframes`