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 &&
配置导入/导出