Merge pull request #70 from LmeSzinc/dev

Add Dashboard
This commit is contained in:
LmeSzinc 2023-08-30 00:59:49 +08:00 committed by GitHub
commit 72f1842e44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 672 additions and 83 deletions

View File

@ -60,3 +60,10 @@
#pywebio-scope-log { #pywebio-scope-log {
overflow-y: auto; overflow-y: auto;
} }
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
min-width: 4rem;
max-width: 50%;
}

View File

@ -26,7 +26,16 @@
overflow-y: auto; overflow-y: auto;
} }
#pywebio-scope-scheduler-bar, #pywebio-scope-log-bar {
display: flex;
flex-direction: column;
height: 11.5rem;
}
#pywebio-scope-dashboard {
flex: 1;
}
#pywebio-scope-log-bar, #pywebio-scope-log-bar,
#pywebio-scope-log, #pywebio-scope-log,
#pywebio-scope-daemon-overview #pywebio-scope-groups { #pywebio-scope-daemon-overview #pywebio-scope-groups {

View File

@ -383,17 +383,68 @@ pre.rich-traceback-code {
} }
#pywebio-scope-scheduler-bar, #pywebio-scope-scheduler-bar,
#pywebio-scope-log-bar { #pywebio-scope-log-title {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
#pywebio-scope-log-bar-btns { #pywebio-scope-log-title-btns {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
} }
#pywebio-scope-dashboard {
display: flex;
align-content: space-between;
justify-content: flex-start;
flex-flow: row wrap;
overflow: auto;
margin-top: .5rem;
}
#pywebio-scope-dashboard > i {
flex-grow: 1;
align-self: flex-end;
width: 10rem;
}
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
width: 10rem;
}
.dashboard-icon {
margin: .6rem .8rem 0 .6rem;
width: .5rem;
height: .5rem;
border-radius: 50%;
}
*[style*="--dashboard-value--"] {
font-size: 1rem;
font-weight: 500;
overflow-wrap: break-word;
}
*[style*="--dashboard-time--"] {
font-size: 0.8rem;
font-weight: 400;
overflow-wrap: break-word;
}
[id^="pywebio-scope-dashboard-row-"] p {
margin-bottom: 0;
}
[id^="pywebio-scope-dashboard-value-"] {
display: flex;
align-items: flex-end;
height: 1.5rem;
}
#pywebio-scope-log { #pywebio-scope-log {
line-height: 1.2; line-height: 1.2;
font-size: 0.85rem; font-size: 0.85rem;

View File

@ -153,3 +153,7 @@ pre.rich-traceback-code {
[id^="pywebio-scope-group_"] > p + p { [id^="pywebio-scope-group_"] > p + p {
color: #adb5bd; color: #adb5bd;
} }
*[style*="--dashboard-time--"] {
color: #adb5bd;
}

View File

@ -152,3 +152,7 @@ pre.rich-traceback-code {
[id^="pywebio-scope-group_"] > p + p { [id^="pywebio-scope-group_"] > p + p {
color: #777777; color: #777777;
} }
*[style*="--dashboard-time--"] {
color: #777777;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

View File

@ -57,7 +57,8 @@
}, },
"DungeonStorage": { "DungeonStorage": {
"TrailblazePower": {}, "TrailblazePower": {},
"DungeonDouble": {} "DungeonDouble": {},
"SimulatedUniverse": {}
} }
}, },
"DailyQuest": { "DailyQuest": {
@ -119,7 +120,8 @@
"Name_2": "Akashic_Records", "Name_2": "Akashic_Records",
"Name_3": "The_Invisible_Hand", "Name_3": "The_Invisible_Hand",
"Name_4": "Nine_Billion_Names", "Name_4": "Nine_Billion_Names",
"Duration": 20 "Duration": 20,
"Assignment": {}
} }
} }
} }

View File

@ -364,13 +364,23 @@
"type": "stored", "type": "stored",
"value": {}, "value": {},
"display": "hide", "display": "hide",
"stored": "StoredTrailblazePower" "stored": "StoredTrailblazePower",
"order": 1,
"color": "#eb8efe"
}, },
"DungeonDouble": { "DungeonDouble": {
"type": "stored", "type": "stored",
"value": {}, "value": {},
"display": "hide", "display": "hide",
"stored": "StoredDungeonDouble" "stored": "StoredDungeonDouble"
},
"SimulatedUniverse": {
"type": "stored",
"value": {},
"display": "hide",
"stored": "StoredSimulatedUniverse",
"order": 5,
"color": "#8fb5fe"
} }
} }
}, },
@ -782,7 +792,9 @@
"type": "stored", "type": "stored",
"value": {}, "value": {},
"display": "hide", "display": "hide",
"stored": "StoredDailyActivity" "stored": "StoredDailyActivity",
"order": 2,
"color": "#ffcf70"
}, },
"DailyQuest": { "DailyQuest": {
"type": "stored", "type": "stored",
@ -931,6 +943,14 @@
12, 12,
20 20
] ]
},
"Assignment": {
"type": "stored",
"value": {},
"display": "hide",
"stored": "StoredAssignment",
"order": 3,
"color": "#deba95"
} }
} }
} }

View File

@ -111,8 +111,14 @@ DungeonSupport:
DungeonStorage: DungeonStorage:
TrailblazePower: TrailblazePower:
stored: StoredTrailblazePower stored: StoredTrailblazePower
order: 1
color: "#eb8efe"
DungeonDouble: DungeonDouble:
stored: StoredDungeonDouble stored: StoredDungeonDouble
SimulatedUniverse:
stored: StoredSimulatedUniverse
order: 5
color: "#8fb5fe"
AchievableQuest: AchievableQuest:
# Quests will be injected in config updater # Quests will be injected in config updater
@ -124,6 +130,8 @@ AchievableQuest:
DailyStorage: DailyStorage:
DailyActivity: DailyActivity:
stored: StoredDailyActivity stored: StoredDailyActivity
order: 2
color: "#ffcf70"
DailyQuest: DailyQuest:
stored: StoredDaily stored: StoredDaily
@ -144,3 +152,7 @@ Assignment:
Duration: Duration:
value: 20 value: 20
option: [ 4, 8, 12, 20 ] option: [ 4, 8, 12, 20 ]
Assignment:
stored: StoredAssignment
order: 3
color: "#deba95"

View File

@ -52,6 +52,16 @@ Overview:
Waiting: Waiting:
NoTask: NoTask:
Dashboard:
# From lang.readable_time()
NoData:
TimeError:
JustNow:
MinutesAgo:
HoursAgo:
DaysAgo:
LongTimeAgo:
AddAlas: AddAlas:
PopupTitle: PopupTitle:
NewName: NewName:

View File

@ -0,0 +1,84 @@
{
"TrailblazePower": {
"name": "TrailblazePower",
"path": "Dungeon.DungeonStorage.TrailblazePower",
"i18n": "DungeonStorage.TrailblazePower.name",
"stored": "StoredTrailblazePower",
"attrs": {
"time": "2020-01-01 00:00:00",
"total": 180,
"value": 0
},
"order": 1,
"color": "#eb8efe"
},
"DailyActivity": {
"name": "DailyActivity",
"path": "DailyQuest.DailyStorage.DailyActivity",
"i18n": "DailyStorage.DailyActivity.name",
"stored": "StoredDailyActivity",
"attrs": {
"time": "2020-01-01 00:00:00",
"total": 500,
"value": 0
},
"order": 2,
"color": "#ffcf70"
},
"Assignment": {
"name": "Assignment",
"path": "Assignment.Assignment.Assignment",
"i18n": "Assignment.Assignment.name",
"stored": "StoredAssignment",
"attrs": {
"time": "2020-01-01 00:00:00",
"total": 0,
"value": 0
},
"order": 3,
"color": "#deba95"
},
"SimulatedUniverse": {
"name": "SimulatedUniverse",
"path": "Dungeon.DungeonStorage.SimulatedUniverse",
"i18n": "DungeonStorage.SimulatedUniverse.name",
"stored": "StoredSimulatedUniverse",
"attrs": {
"time": "2020-01-01 00:00:00",
"total": 0,
"value": 0
},
"order": 5,
"color": "#8fb5fe"
},
"DungeonDouble": {
"name": "DungeonDouble",
"path": "Dungeon.DungeonStorage.DungeonDouble",
"i18n": "DungeonStorage.DungeonDouble.name",
"stored": "StoredDungeonDouble",
"attrs": {
"time": "2020-01-01 00:00:00",
"calyx": 0,
"relic": 0
},
"order": 0,
"color": "#777777"
},
"DailyQuest": {
"name": "DailyQuest",
"path": "DailyQuest.DailyStorage.DailyQuest",
"i18n": "DailyStorage.DailyQuest.name",
"stored": "StoredDaily",
"attrs": {
"time": "2020-01-01 00:00:00",
"quest1": "",
"quest2": "",
"quest3": "",
"quest4": "",
"quest5": "",
"quest6": ""
},
"order": 0,
"color": "#777777"
}
}

View File

@ -57,6 +57,7 @@ class GeneratedConfig:
# Group `DungeonStorage` # Group `DungeonStorage`
DungeonStorage_TrailblazePower = {} DungeonStorage_TrailblazePower = {}
DungeonStorage_DungeonDouble = {} DungeonStorage_DungeonDouble = {}
DungeonStorage_SimulatedUniverse = {}
# Group `AchievableQuest` # Group `AchievableQuest`
AchievableQuest_Complete_1_Daily_Mission = 'achievable' # achievable, not_set, not_supported AchievableQuest_Complete_1_Daily_Mission = 'achievable' # achievable, not_set, not_supported
@ -95,3 +96,4 @@ class GeneratedConfig:
Assignment_Name_3 = 'The_Invisible_Hand' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm Assignment_Name_3 = 'The_Invisible_Hand' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm
Assignment_Name_4 = 'Nine_Billion_Names' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm Assignment_Name_4 = 'Nine_Billion_Names' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm
Assignment_Duration = 20 # 4, 8, 12, 20 Assignment_Duration = 20 # 4, 8, 12, 20
Assignment_Assignment = {}

View File

@ -441,6 +441,32 @@ class ConfigGenerator:
return data return data
@cached_property
def stored(self):
import module.config.stored.classes as classes
data = {}
for path, value in deep_iter(self.args, depth=3):
if value.get('type') != 'stored':
continue
name = path[-1]
stored = value.get('stored')
stored_class = getattr(classes, stored)
row = {
'name': name,
'path': '.'.join(path),
'i18n': f'{path[1]}.{path[2]}.name',
'stored': stored,
'attrs': stored_class('')._attrs,
'order': value.get('order', 0),
'color': value.get('color', '#777777')
}
data[name] = row
# sort by `order` ascending, but `order`==0 at last
data = sorted(data.items(), key=lambda kv: (kv[1]['order'] == 0, kv[1]['order']))
data = {k: v for k, v in data}
return data
@staticmethod @staticmethod
def generate_deploy_template(): def generate_deploy_template():
template = poor_yaml_read(DEPLOY_TEMPLATE) template = poor_yaml_read(DEPLOY_TEMPLATE)
@ -495,12 +521,14 @@ class ConfigGenerator:
def generate(self): def generate(self):
_ = self.args _ = self.args
_ = self.menu _ = self.menu
_ = self.stored
# _ = self.event # _ = self.event
self.insert_assignment() self.insert_assignment()
self.insert_package() self.insert_package()
# self.insert_server() # self.insert_server()
write_file(filepath_args(), self.args) write_file(filepath_args(), self.args)
write_file(filepath_args('menu'), self.menu) write_file(filepath_args('menu'), self.menu)
write_file(filepath_args('stored'), self.stored)
self.generate_code() self.generate_code()
self.generate_stored() self.generate_stored()
for lang in LANGUAGES: for lang in LANGUAGES:

View File

@ -364,6 +364,10 @@
"DungeonDouble": { "DungeonDouble": {
"name": "Dungeon Double", "name": "Dungeon Double",
"help": "" "help": ""
},
"SimulatedUniverse": {
"name": "Sim.Uni.",
"help": ""
} }
}, },
"AchievableQuest": { "AchievableQuest": {
@ -641,6 +645,10 @@
"8": "8 Hours", "8": "8 Hours",
"12": "12 Hours", "12": "12 Hours",
"20": "20 Hours" "20": "20 Hours"
},
"Assignment": {
"name": "Assignment",
"help": ""
} }
}, },
"Gui": { "Gui": {
@ -695,6 +703,15 @@
"Waiting": "Waiting", "Waiting": "Waiting",
"NoTask": "No Task" "NoTask": "No Task"
}, },
"Dashboard": {
"NoData": "no data",
"TimeError": "time error",
"JustNow": "just now",
"MinutesAgo": "{time}min ago",
"HoursAgo": "{time}h ago",
"DaysAgo": "{time}d ago",
"LongTimeAgo": "long time ago"
},
"AddAlas": { "AddAlas": {
"PopupTitle": "Add new config", "PopupTitle": "Add new config",
"NewName": "New name", "NewName": "New name",

View File

@ -364,6 +364,10 @@
"DungeonDouble": { "DungeonDouble": {
"name": "DungeonStorage.DungeonDouble.name", "name": "DungeonStorage.DungeonDouble.name",
"help": "DungeonStorage.DungeonDouble.help" "help": "DungeonStorage.DungeonDouble.help"
},
"SimulatedUniverse": {
"name": "DungeonStorage.SimulatedUniverse.name",
"help": "DungeonStorage.SimulatedUniverse.help"
} }
}, },
"AchievableQuest": { "AchievableQuest": {
@ -641,6 +645,10 @@
"8": "8", "8": "8",
"12": "12", "12": "12",
"20": "20" "20": "20"
},
"Assignment": {
"name": "Assignment.Assignment.name",
"help": "Assignment.Assignment.help"
} }
}, },
"Gui": { "Gui": {
@ -695,6 +703,15 @@
"Waiting": "Waiting", "Waiting": "Waiting",
"NoTask": "No Task" "NoTask": "No Task"
}, },
"Dashboard": {
"NoData": "Gui.Dashboard.NoData",
"TimeError": "Gui.Dashboard.TimeError",
"JustNow": "Gui.Dashboard.JustNow",
"MinutesAgo": "Gui.Dashboard.MinutesAgo",
"HoursAgo": "Gui.Dashboard.HoursAgo",
"DaysAgo": "Gui.Dashboard.DaysAgo",
"LongTimeAgo": "Gui.Dashboard.LongTimeAgo"
},
"AddAlas": { "AddAlas": {
"PopupTitle": "新しいコンフィグを追加", "PopupTitle": "新しいコンフィグを追加",
"NewName": "コンフィグ名", "NewName": "コンフィグ名",

View File

@ -364,6 +364,10 @@
"DungeonDouble": { "DungeonDouble": {
"name": "副本双倍", "name": "副本双倍",
"help": "" "help": ""
},
"SimulatedUniverse": {
"name": "模拟宇宙",
"help": ""
} }
}, },
"AchievableQuest": { "AchievableQuest": {
@ -641,6 +645,10 @@
"8": "8小时", "8": "8小时",
"12": "12小时", "12": "12小时",
"20": "20小时" "20": "20小时"
},
"Assignment": {
"name": "委托",
"help": ""
} }
}, },
"Gui": { "Gui": {
@ -695,6 +703,15 @@
"Waiting": "等待中", "Waiting": "等待中",
"NoTask": "无任务" "NoTask": "无任务"
}, },
"Dashboard": {
"NoData": "无数据",
"TimeError": "时间错误",
"JustNow": "刚刚",
"MinutesAgo": "{time}分钟前",
"HoursAgo": "{time}小时前",
"DaysAgo": "{time}天前",
"LongTimeAgo": "很久以前"
},
"AddAlas": { "AddAlas": {
"PopupTitle": "添加新配置", "PopupTitle": "添加新配置",
"NewName": "新的配置文件名", "NewName": "新的配置文件名",

View File

@ -364,6 +364,10 @@
"DungeonDouble": { "DungeonDouble": {
"name": "副本雙倍", "name": "副本雙倍",
"help": "" "help": ""
},
"SimulatedUniverse": {
"name": "模擬宇宙",
"help": ""
} }
}, },
"AchievableQuest": { "AchievableQuest": {
@ -641,6 +645,10 @@
"8": "8小時", "8": "8小時",
"12": "12小時", "12": "12小時",
"20": "20小時" "20": "20小時"
},
"Assignment": {
"name": "委託",
"help": ""
} }
}, },
"Gui": { "Gui": {
@ -695,6 +703,15 @@
"Waiting": "等待中", "Waiting": "等待中",
"NoTask": "無任務" "NoTask": "無任務"
}, },
"Dashboard": {
"NoData": "無數據",
"TimeError": "時間錯誤",
"JustNow": "剛剛",
"MinutesAgo": "{time}分鐘前",
"HoursAgo": "{time}小時前",
"DaysAgo": "{time}天前",
"LongTimeAgo": "很久以前"
},
"AddAlas": { "AddAlas": {
"PopupTitle": "添加新的設定", "PopupTitle": "添加新的設定",
"NewName": "新設定的檔案名", "NewName": "新設定的檔案名",

View File

@ -1,4 +1,3 @@
import time
from datetime import datetime from datetime import datetime
from functools import cached_property as functools_cached_property from functools import cached_property as functools_cached_property
@ -111,29 +110,6 @@ class StoredBase:
from module.logger import logger from module.logger import logger
logger.attr(self._name, self._stored) logger.attr(self._name, self._stored)
def dashboard(self) -> str:
"""
Return a string to show on GUI
"""
return 'None'
def readable_time(self):
diff = self.time.timestamp() - time.time()
if diff < -1:
return '', 'TimeError'
elif diff < 60:
# < 1 min
return '', 'JustNow'
elif diff < 3600:
return str(int(diff // 60)), 'MinutesAgo'
elif diff < 86400:
return str(int(diff // 86400)), 'HoursAgo'
elif diff < 129600:
return str(int(diff // 129600)), 'DaysAgo'
else:
# > 15 days
return '', 'LongTimeAgo'
class StoredExpiredAt0400(StoredBase): class StoredExpiredAt0400(StoredBase):
def is_expired(self): def is_expired(self):
@ -154,11 +130,11 @@ class StoredCounter(StoredBase):
FIXED_TOTAL = 0 FIXED_TOTAL = 0
def set(self, current, total=0): def set(self, value, total=0):
if self.FIXED_TOTAL: if self.FIXED_TOTAL:
total = self.FIXED_TOTAL total = self.FIXED_TOTAL
with self._config.multi_set(): with self._config.multi_set():
self.value = current self.value = value
self.total = total self.total = total
def to_counter(self) -> str: def to_counter(self) -> str:
@ -170,6 +146,13 @@ class StoredCounter(StoredBase):
def get_remain(self) -> int: def get_remain(self) -> int:
return self.total - self.value return self.total - self.value
@cached_property
def _attrs(self) -> dict:
attrs = super()._attrs
if self.FIXED_TOTAL:
attrs['total'] = self.FIXED_TOTAL
return attrs
@functools_cached_property @functools_cached_property
def _stored(self): def _stored(self):
stored = super()._stored stored = super()._stored
@ -186,6 +169,14 @@ class StoredTrailblazePower(StoredCounter):
FIXED_TOTAL = 180 FIXED_TOTAL = 180
class StoredSimulatedUniverse(StoredCounter, StoredExpiredAt0400):
pass
class StoredAssignment(StoredCounter):
pass
class StoredDaily(StoredExpiredAt0400): class StoredDaily(StoredExpiredAt0400):
quest1 = '' quest1 = ''
quest2 = '' quest2 = ''

View File

@ -1,4 +1,5 @@
from module.config.stored.classes import ( from module.config.stored.classes import (
StoredAssignment,
StoredBase, StoredBase,
StoredCounter, StoredCounter,
StoredDaily, StoredDaily,
@ -6,6 +7,7 @@ from module.config.stored.classes import (
StoredDungeonDouble, StoredDungeonDouble,
StoredExpiredAt0400, StoredExpiredAt0400,
StoredInt, StoredInt,
StoredSimulatedUniverse,
StoredTrailblazePower, StoredTrailblazePower,
) )
@ -16,5 +18,7 @@ from module.config.stored.classes import (
class StoredGenerated: class StoredGenerated:
TrailblazePower = StoredTrailblazePower("Dungeon.DungeonStorage.TrailblazePower") TrailblazePower = StoredTrailblazePower("Dungeon.DungeonStorage.TrailblazePower")
DungeonDouble = StoredDungeonDouble("Dungeon.DungeonStorage.DungeonDouble") DungeonDouble = StoredDungeonDouble("Dungeon.DungeonStorage.DungeonDouble")
SimulatedUniverse = StoredSimulatedUniverse("Dungeon.DungeonStorage.SimulatedUniverse")
DailyActivity = StoredDailyActivity("DailyQuest.DailyStorage.DailyActivity") DailyActivity = StoredDailyActivity("DailyQuest.DailyStorage.DailyActivity")
DailyQuest = StoredDaily("DailyQuest.DailyStorage.DailyQuest") DailyQuest = StoredDaily("DailyQuest.DailyStorage.DailyQuest")
Assignment = StoredAssignment("Assignment.Assignment.Assignment")

View File

@ -88,11 +88,13 @@ task_handler = TaskHandler()
class AlasGUI(Frame): class AlasGUI(Frame):
ALAS_MENU: Dict[str, Dict[str, List[str]]] ALAS_MENU: Dict[str, Dict[str, List[str]]]
ALAS_ARGS: Dict[str, Dict[str, Dict[str, Dict[str, str]]]] ALAS_ARGS: Dict[str, Dict[str, Dict[str, Dict[str, str]]]]
ALAS_STORED: Dict[str, Dict[str, Dict[str, str]]]
theme = "default" theme = "default"
def initial(self) -> None: def initial(self) -> None:
self.ALAS_MENU = read_file(filepath_args("menu", self.alas_mod)) self.ALAS_MENU = read_file(filepath_args("menu", self.alas_mod))
self.ALAS_ARGS = read_file(filepath_args("args", self.alas_mod)) self.ALAS_ARGS = read_file(filepath_args("args", self.alas_mod))
self.ALAS_STORED = read_file(filepath_args("stored", self.alas_mod))
self._init_alas_config_watcher() self._init_alas_config_watcher()
def __init__(self) -> None: def __init__(self) -> None:
@ -318,6 +320,35 @@ class AlasGUI(Frame):
color="navigator", color="navigator",
) )
def set_dashboard(self, arg, arg_dict, config):
i18n = arg_dict.get('i18n')
if i18n:
name = t(i18n)
else:
name = arg
color = arg_dict.get("color", "#777777")
nodata = t("Gui.Dashboard.NoData")
def set_value(dic):
if "total" in dic.get("attrs", []) and config.get("total") is not None:
return [
put_text(config.get("value", nodata)).style("--dashboard-value--"),
put_text(f' / {config.get("total", "")}').style("--dashboard-time--"),
]
else:
return [
put_text(config.get("value", nodata)).style("--dashboard-value--"),
]
with use_scope(f"dashboard-row-{arg}", clear=True):
put_html(f'<div><div class="dashboard-icon" style="background-color:{color}"></div>'),
put_scope(f"dashboard-content-{arg}", [
put_scope(f"dashboard-value-{arg}", set_value(arg_dict)),
put_scope(f"dashboard-time-{arg}", [
put_text(f"{name} - {lang.readable_time(config.get('time', ''))}").style("--dashboard-time--"),
])
])
@use_scope("content", clear=True) @use_scope("content", clear=True)
def alas_overview(self) -> None: def alas_overview(self) -> None:
self.init_menu(name="Overview") self.init_menu(name="Overview")
@ -374,20 +405,20 @@ class AlasGUI(Frame):
log = RichLog("log") log = RichLog("log")
with use_scope("logs"): with use_scope("logs"):
put_scope( put_scope("log-bar", [
"log-bar", put_scope("log-title", [
[ put_text(t("Gui.Overview.Log")).style("font-size: 1.25rem; margin: auto .5rem auto;"),
put_text(t("Gui.Overview.Log")).style( put_scope("log-title-btns", [
"font-size: 1.25rem; margin: auto .5rem auto;"
),
put_scope(
"log-bar-btns",
[
put_scope("log_scroll_btn"), put_scope("log_scroll_btn"),
], ]),
), ]),
], put_html('<hr class="hr-group">'),
), put_scope("dashboard", [
# Empty dashboard, values will be updated in alas_update_overview_task()
put_scope(f"dashboard-row-{arg}", []) for arg in self.ALAS_STORED.keys()
# Empty content to left-align last row
] + [put_html("<i></i>")] * len(self.ALAS_STORED))
])
put_scope("log", [put_html("")]) put_scope("log", [put_html("")])
log.console.width = log.get_width() log.console.width = log.get_width()
@ -501,6 +532,7 @@ class AlasGUI(Frame):
self.alas_config.load() self.alas_config.load()
self.alas_config.get_next_task() self.alas_config.get_next_task()
alive = self.alas.alive
if len(self.alas_config.pending_task) >= 1: if len(self.alas_config.pending_task) >= 1:
if self.alas.alive: if self.alas.alive:
running = self.alas_config.pending_task[:1] running = self.alas_config.pending_task[:1]
@ -528,6 +560,8 @@ class AlasGUI(Frame):
color="off", color="off",
) )
if self.scope_expired("scheduler_alive", alive) \
or self.scope_expired("pending_task", self.alas_config.pending_task):
clear("running_tasks") clear("running_tasks")
clear("pending_tasks") clear("pending_tasks")
clear("waiting_tasks") clear("waiting_tasks")
@ -549,6 +583,16 @@ class AlasGUI(Frame):
put_task(task) put_task(task)
else: else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--") put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
self.scope_add("scheduler_alive", alive)
self.scope_add("pending_task", self.alas_config.pending_task)
for arg, arg_dict in self.ALAS_STORED.items():
path = arg_dict["path"]
if self.scope_expired_then_add(
key=f"dashboard-time-value-{arg}",
value=lang.readable_time(deep_get(self.alas_config.data, keys=f"{path}.time"))
):
self.set_dashboard(arg, arg_dict, deep_get(self.alas_config.data, keys=path, default={}))
@use_scope("content", clear=True) @use_scope("content", clear=True)
def alas_daemon_overview(self, task: str) -> None: def alas_daemon_overview(self, task: str) -> None:

View File

@ -1,3 +1,5 @@
from typing import Any, Dict
from pywebio.output import clear, put_html, put_scope, put_text, use_scope from pywebio.output import clear, put_html, put_scope, put_text, use_scope
from pywebio.session import defer_call, info, run_js from pywebio.session import defer_call, info, run_js
@ -13,12 +15,34 @@ class Base:
self.is_mobile = info.user_agent.is_mobile self.is_mobile = info.user_agent.is_mobile
# Task handler # Task handler
self.task_handler = WebIOTaskHandler() self.task_handler = WebIOTaskHandler()
# Record scopes to reduce data transfer to frontend
# Key: scope name, value: last update time
self.scope: Dict[str, Any] = {}
defer_call(self.stop) defer_call(self.stop)
def stop(self) -> None: def stop(self) -> None:
self.alive = False self.alive = False
self.task_handler.stop() self.task_handler.stop()
def scope_clear(self):
self.scope = {}
def scope_add(self, key, value):
self.scope[key] = value
def scope_expired(self, key, value) -> bool:
try:
return self.scope[key] != value
except KeyError:
return True
def scope_expired_then_add(self, key, value) -> bool:
if self.scope_expired(key, value):
self.scope_add(key, value)
return True
else:
return False
class Frame(Base): class Frame(Base):
def __init__(self) -> None: def __init__(self) -> None:
@ -33,6 +57,7 @@ class Frame(Base):
name: button name(label) to be highlight name: button name(label) to be highlight
""" """
self.visible = True self.visible = True
self.scope_clear()
self.task_handler.remove_pending_task() self.task_handler.remove_pending_task()
clear("menu") clear("menu")
if expand_menu: if expand_menu:
@ -50,6 +75,7 @@ class Frame(Base):
""" """
self.visible = True self.visible = True
self.page = name self.page = name
self.scope_clear()
self.task_handler.remove_pending_task() self.task_handler.remove_pending_task()
clear("content") clear("content")
if collapse_menu: if collapse_menu:

View File

@ -1,8 +1,9 @@
import time
from typing import Dict from typing import Dict
from module.config.utils import * from module.config.utils import *
from module.webui.setting import State
from module.webui.fake import list_mod from module.webui.fake import list_mod
from module.webui.setting import State
LANG = "zh-CN" LANG = "zh-CN"
TRANSLATE_MODE = False TRANSLATE_MODE = False
@ -67,3 +68,33 @@ def reload():
for key in dic_lang["ja-JP"].keys(): for key in dic_lang["ja-JP"].keys():
if dic_lang["ja-JP"][key] == key: if dic_lang["ja-JP"][key] == key:
dic_lang["ja-JP"][key] = dic_lang["en-US"][key] dic_lang["ja-JP"][key] = dic_lang["en-US"][key]
def readable_time(before: str) -> str:
"""
Convert "2023-08-29 12:30:53" to "3 Minutes Ago"
"""
if not before:
return t("Gui.Dashboard.NoData")
try:
ti = datetime.fromisoformat(before)
except ValueError:
return t("Gui.Dashboard.TimeError")
if ti == DEFAULT_TIME:
return t("Gui.Dashboard.NoData")
diff = time.time() - ti.timestamp()
if diff < -1:
return t("Gui.Dashboard.TimeError")
elif diff < 60:
# < 1 min
return t("Gui.Dashboard.JustNow")
elif diff < 3600:
return t("Gui.Dashboard.MinutesAgo", time=int(diff // 60))
elif diff < 86400:
return t("Gui.Dashboard.HoursAgo", time=int(diff // 3600))
elif diff < 129600:
return t("Gui.Dashboard.DaysAgo", time=int(diff // 86400))
else:
# > 15 days
return t("Gui.Dashboard.LongTimeAgo")

View File

@ -145,7 +145,14 @@ class AssignmentUI(UI):
@property @property
def _limit_status(self) -> tuple[int, int, int]: def _limit_status(self) -> tuple[int, int, int]:
self.device.screenshot() self.device.screenshot()
return DigitCounter(OCR_ASSIGNMENT_LIMIT).ocr_single_line(self.device.image) current, remain, total = DigitCounter(OCR_ASSIGNMENT_LIMIT).ocr_single_line(self.device.image)
if total and current <= total:
logger.attr('Assignment', f'{current}/{total}')
self.config.stored.Assignment.set(current, total)
else:
logger.warning(f'Invalid assignment limit: {current}/{total}')
self.config.stored.Assignment.set(0, 0)
return current, remain, total
def _iter_groups(self) -> Iterator[AssignmentGroup]: def _iter_groups(self) -> Iterator[AssignmentGroup]:
for state in ASSIGNMENT_TOP_SWITCH.state_list: for state in ASSIGNMENT_TOP_SWITCH.state_list:

View File

@ -1,23 +1,36 @@
import cv2 import cv2
import numpy as np import numpy as np
from scipy import signal from scipy import signal
from module.base.button import Button, ButtonWrapper
from module.base.timer import Timer from module.base.timer import Timer
from module.base.utils import area_size, crop, rgb2luma, load_image from module.base.utils import area_size, crop, rgb2luma, load_image, crop
from module.logger import logger from module.logger import logger
from module.ui.scroll import Scroll from module.ui.scroll import Scroll
from tasks.base.assets.assets_base_popup import CANCEL_POPUP
from tasks.base.ui import UI from tasks.base.ui import UI
from tasks.combat.assets.assets_combat_support import COMBAT_SUPPORT_ADD, COMBAT_SUPPORT_LIST, \ from tasks.combat.assets.assets_combat_support import COMBAT_SUPPORT_ADD, COMBAT_SUPPORT_LIST, \
COMBAT_SUPPORT_LIST_SCROLL, COMBAT_SUPPORT_SELECTED COMBAT_SUPPORT_LIST_SCROLL, COMBAT_SUPPORT_SELECTED, COMBAT_SUPPORT_LIST_GRID
from tasks.combat.assets.assets_combat_team import COMBAT_TEAM_SUPPORT, COMBAT_TEAM_DISMISSSUPPORT from tasks.combat.assets.assets_combat_team import COMBAT_TEAM_SUPPORT, COMBAT_TEAM_DISMISSSUPPORT
def get_position_in_original_image(position_in_croped_image, crop_area):
"""
Returns:
tuple: (x, y) of position in original image
"""
return (
position_in_croped_image[0] + crop_area[0], position_in_croped_image[1] + crop_area[1]) if position_in_croped_image else None
class SupportCharacter: class SupportCharacter:
_image_cache = {} _image_cache = {}
_crop_area = COMBAT_SUPPORT_LIST_GRID.matched_button.area
def __init__(self, name, screenshot, similarity=0.85): def __init__(self, name, screenshot, similarity=0.85):
self.name = name self.name = name
self.image = self._scale_character() self.image = self._scale_character()
self.screenshot = screenshot self.screenshot = crop(screenshot, SupportCharacter._crop_area)
self.similarity = similarity self.similarity = similarity
self.button = self._find_character() self.button = self._find_character()
@ -49,10 +62,12 @@ class SupportCharacter:
def _find_character(self): def _find_character(self):
character = np.array(self.image) character = np.array(self.image)
support_list_img = self.screenshot support_list_img = self.screenshot
res = cv2.matchTemplate(character, support_list_img, cv2.TM_CCOEFF_NORMED) res = cv2.matchTemplate(
character, support_list_img, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res) _, max_val, _, max_loc = cv2.minMaxLoc(res)
max_loc = get_position_in_original_image(
max_loc, SupportCharacter._crop_area)
character_width = character.shape[1] character_width = character.shape[1]
character_height = character.shape[0] character_height = character.shape[0]
@ -68,6 +83,66 @@ class SupportCharacter:
self.button[0], self.button[1] - 5, self.button[0] + 30, self.button[1]) if self.button else None self.button[0], self.button[1] - 5, self.button[0] + 30, self.button[1]) if self.button else None
class ArrowWrapper(ButtonWrapper):
def find_center(self, image):
res = cv2.matchTemplate(
self.matched_button.image, image, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
return (
(
(max_loc[0] + self.matched_button.image.shape[1] / 2),
(max_loc[1] + self.matched_button.image.shape[0] / 2),
)
if max_val > 0.75
else None
)
class NextSupportCharacter:
_arrow = ArrowWrapper(
name="NextSupportCharacterArrow",
share=Button(
file='./assets/support/selected_character_arrow.png',
area=None,
search=None,
color=None,
button=None,
)
)
_crop_area = (290, 115, 435, 634)
def __init__(self, screenshot):
self.name = "SupportCharacterArrow"
self.screenshot = crop(screenshot, NextSupportCharacter._crop_area)
self.arrow_center = self._find_center()
self.button = self._get_next_support_character_button()
def __bool__(self):
return self.button is not None
def _find_center(self):
center = NextSupportCharacter._arrow.find_center(self.screenshot)
center = get_position_in_original_image(
center, NextSupportCharacter._crop_area) if center else None
return center
def _get_next_support_character_button(self):
area = (self.arrow_center[0] - 200, min(self.arrow_center[1] + 65, 615), self.arrow_center[0] + 10, min(
self.arrow_center[1] + 80, 620)) if self.arrow_center and self.arrow_center[1] < 510 else None
return ButtonWrapper(
name="NextSupportCharacterButton",
share=Button(
file='./assets/support/selected_character_arrow.png',
area=area,
search=area,
# if next support was selected, the average color of the button will larger than 220
color=(220, 220, 220),
button=area,
)
) if self.arrow_center and self.arrow_center[1] < 510 else None
class SupportListScroll(Scroll): class SupportListScroll(Scroll):
def cal_position(self, main): def cal_position(self, main):
""" """
@ -130,11 +205,18 @@ class CombatSupport(UI):
return True return True
# Click # Click
if self.appear(COMBAT_TEAM_SUPPORT, interval=2): if self.appear(COMBAT_TEAM_SUPPORT, interval=1):
self.device.click(COMBAT_TEAM_SUPPORT) self.device.click(COMBAT_TEAM_SUPPORT)
self.interval_reset(COMBAT_TEAM_SUPPORT) self.interval_reset(COMBAT_TEAM_SUPPORT)
continue continue
if self.appear(COMBAT_SUPPORT_LIST, interval=2): if self.appear(CANCEL_POPUP, interval=1):
logger.warning(
"selected identical character, trying select another")
self._cancel_popup()
self._select_next_support()
self.interval_reset(CANCEL_POPUP)
continue
if self.appear(COMBAT_SUPPORT_LIST, interval=1):
if support_character_name != "FirstCharacter": if support_character_name != "FirstCharacter":
self._search_support( self._search_support(
support_character_name) # Search support support_character_name) # Search support
@ -167,7 +249,6 @@ class CombatSupport(UI):
logger.info("Searching support") logger.info("Searching support")
skip_first_screenshot = False skip_first_screenshot = False
character = None
while 1: while 1:
if skip_first_screenshot: if skip_first_screenshot:
skip_first_screenshot = False skip_first_screenshot = False
@ -175,7 +256,8 @@ class CombatSupport(UI):
self.device.screenshot() self.device.screenshot()
if not support_character_name.startswith("Trailblazer"): if not support_character_name.startswith("Trailblazer"):
character = SupportCharacter(support_character_name, self.device.image) character = SupportCharacter(
support_character_name, self.device.image)
else: else:
character = SupportCharacter(f"Stelle{support_character_name[11:]}", character = SupportCharacter(f"Stelle{support_character_name[11:]}",
self.device.image) or SupportCharacter( self.device.image) or SupportCharacter(
@ -223,3 +305,73 @@ class CombatSupport(UI):
self.device.click(character) self.device.click(character)
interval.reset() interval.reset()
continue continue
def _cancel_popup(self):
"""
Pages:
in: CANCEL_POPUP
out: COMBAT_SUPPORT_LIST
"""
logger.hr("Combat support cancel popup")
skip_first_screenshot = True
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
# End
if self.appear(COMBAT_SUPPORT_LIST):
logger.info("Popup canceled")
return
if self.appear(CANCEL_POPUP):
self.device.click(CANCEL_POPUP)
continue
def _select_next_support(self):
"""
Pages:
in: COMBAT_SUPPORT_LIST
out: COMBAT_SUPPORT_LIST
"""
skip_first_screenshot = True
scroll = SupportListScroll(area=COMBAT_SUPPORT_LIST_SCROLL.area, color=(194, 196, 205),
name=COMBAT_SUPPORT_LIST_SCROLL.name)
interval = Timer(1)
next_support = None
if scroll.appear(main=self):
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
# End
if next_support and self._next_support_selected(next_support):
return
if interval.reached():
next_support = NextSupportCharacter(self.device.image)
if next_support:
logger.info("Next support found, clicking")
self.device.click(next_support.button)
elif not scroll.at_bottom(main=self):
scroll.next_page(main=self, page=0.4)
else:
logger.warning("No more support")
return
interval.reset()
continue
def _next_support_selected(self, next_support: NextSupportCharacter):
"""
Returns:
bool: True if selected else False
"""
if self.match_color(next_support.button, threshold=20):
logger.info("Next support selected")
return True
return False

View File

@ -53,6 +53,16 @@ OCR_DUNGEON_NAV = ButtonWrapper(
button=(108, 132, 428, 613), button=(108, 132, 428, 613),
), ),
) )
OCR_SIMUNI_POINT = ButtonWrapper(
name='OCR_SIMUNI_POINT',
share=Button(
file='./assets/share/dungeon/ui/OCR_SIMUNI_POINT.png',
area=(580, 190, 880, 235),
search=(560, 170, 900, 255),
color=(158, 164, 252),
button=(580, 190, 880, 235),
),
)
OPERATION_BRIEFING_CHECK = ButtonWrapper( OPERATION_BRIEFING_CHECK = ButtonWrapper(
name='OPERATION_BRIEFING_CHECK', name='OPERATION_BRIEFING_CHECK',
share=Button( share=Button(

View File

@ -143,6 +143,9 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
with self.config.multi_set(): with self.config.multi_set():
self.config.stored.DungeonDouble.calyx = calyx self.config.stored.DungeonDouble.calyx = calyx
self.config.stored.DungeonDouble.relic = relic self.config.stored.DungeonDouble.relic = relic
# Update SimulatedUniverse points
logger.info('Get simulated universe points')
self.dungeon_get_simuni_point()
# Run double events # Run double events
ran_calyx_golden = False ran_calyx_golden = False

View File

@ -5,7 +5,7 @@ from module.base.button import ClickButton
from module.base.timer import Timer from module.base.timer import Timer
from module.base.utils import get_color from module.base.utils import get_color
from module.logger import logger from module.logger import logger
from module.ocr.ocr import Ocr, OcrResultButton from module.ocr.ocr import DigitCounter, Ocr, OcrResultButton
from module.ocr.utils import split_and_pair_button_attr from module.ocr.utils import split_and_pair_button_attr
from module.ui.draggable_list import DraggableList from module.ui.draggable_list import DraggableList
from module.ui.switch import Switch from module.ui.switch import Switch
@ -74,6 +74,10 @@ class OcrDungeonList(Ocr):
return result return result
class OcrSimUniPoint(DigitCounter):
merge_thres_x = 50
class OcrDungeonListLimitEntrance(OcrDungeonList): class OcrDungeonListLimitEntrance(OcrDungeonList):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -219,6 +223,22 @@ class DungeonUI(UI):
logger.info('DungeonNav row Forgotten_Hall stabled') logger.info('DungeonNav row Forgotten_Hall stabled')
return True return True
def dungeon_get_simuni_point(self) -> int:
"""
Page:
in: page_guide, Survival_Index, Simulated_Universe
"""
ocr = OcrSimUniPoint(OCR_SIMUNI_POINT)
value, _, total = ocr.ocr_single_line(self.device.image)
if total and value <= total:
logger.attr('SimulatedUniverse', f'{value}/{total}')
self.config.stored.SimulatedUniverse.set(value, total)
return value
else:
logger.warning(f'Invalid SimulatedUniverse points: {value}/{total}')
self.config.stored.SimulatedUniverse.set(0, 0)
return 0
def _dungeon_nav_goto(self, dungeon: DungeonList, skip_first_screenshot=True): def _dungeon_nav_goto(self, dungeon: DungeonList, skip_first_screenshot=True):
""" """
Equivalent to `DUNGEON_NAV_LIST.select_row(dungeon.dungeon_nav, main=self)` Equivalent to `DUNGEON_NAV_LIST.select_row(dungeon.dungeon_nav, main=self)`