DanbooruSearch / webui /render /dialogs.py
SAkizuki's picture
Auto-sync from GitHub Actions
65b0b93 verified
Raw
History Blame Contribute Delete
29.4 kB
"""通用帮助、历史与备份弹窗视图。"""
import json
from typing import Any
from nicegui import ui
from webui.helpers import format_history_settings, format_history_time
def build_sponsor_dialog(
controller: Any,
*,
title: str,
image_url: str,
toolchain_url: str,
ui_text: dict,
) -> None:
"""构建低干扰的赞赏弹窗。"""
with ui.dialog() as controller.sponsor_dialog, ui.card().classes('w-full max-w-sm'):
with ui.column().classes('w-full items-center gap-2 text-center'):
ui.label(title).classes('text-base font-bold text-gray-800')
ui.label(ui_text['sponsor']['body']).classes('text-sm text-gray-600 leading-relaxed')
ui.image(image_url).classes('w-60 max-w-full rounded border border-gray-200')
ui.label('微信赞赏码').classes('text-xs text-gray-400')
ui.link(
ui_text['sponsor']['toolchain_prompt'],
toolchain_url,
new_tab=True,
).classes('text-xs text-blue-500 hover:text-blue-700 hover:underline')
with ui.row().classes('w-full justify-end'):
ui.button('关闭', on_click=controller.sponsor_dialog.close).props('flat color=grey-7')
def build_help_dialog(controller: Any, *, ui_text: dict, sponsor_notice_text: str) -> None:
"""构建帮助、文档与本地数据说明弹窗。"""
from platform_utils import PLATFORM
alternate_url = (
'https://www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline'
if PLATFORM == 'hf'
else 'https://huggingface.co/spaces/SAkizuki/DanbooruSearch'
)
with ui.dialog() as controller.help_dialog, ui.card().classes(
'w-full max-w-3xl max-h-[90vh] p-0 gap-0'
):
with ui.row().classes('w-full items-center justify-between px-5 py-4 border-b border-slate-200'):
with ui.row().classes('items-center gap-2'):
ui.icon('help_outline', color='primary')
ui.label('帮助 / 关于').classes('text-lg font-bold text-slate-800')
ui.button(icon='close', on_click=controller.help_dialog.close).props(
'flat dense round color=grey-7'
)
with ui.scroll_area().classes('w-full h-[72vh]'):
with ui.column().classes('w-full gap-5 px-5 py-4'):
with ui.column().classes('help-section'):
with ui.element('div').classes('help-section-heading'):
ui.label(ui_text['help']['update_title']).classes('help-section-heading-title')
ui.label(ui_text['help']['update_summary']).classes('help-section-heading-subtitle')
ui.markdown(ui_text['help']['guide_markdown']).classes('help-content')
with ui.column().classes('help-section'):
with ui.element('div').classes(
'help-section-heading help-section-heading--documentation'
):
ui.label(ui_text['documentation']['title']).classes('help-section-heading-title')
ui.label(ui_text['documentation']['subtitle']).classes('help-section-heading-subtitle')
with ui.column().classes('help-content gap-3'):
with ui.row().classes('w-full gap-3 flex-wrap'):
for link in ui_text['documentation']['links']:
link_url = alternate_url if link['url'] == '{alternate_url}' else link['url']
ui.link(link['label'], link_url, new_tab=True).classes('help-link')
ui.markdown(ui_text['documentation']['copyright_markdown']).classes(
'help-content'
)
with ui.column().classes('help-section'):
with ui.element('div').classes(
'help-section-heading help-section-heading--notice'
):
ui.label(ui_text['notice']['title']).classes('help-section-heading-title')
ui.label(ui_text['notice']['subtitle']).classes('help-section-heading-subtitle')
ui.markdown(ui_text['notice']['body_markdown']).classes('help-content')
with ui.element('div').classes(
'w-full rounded-lg border border-red-200 bg-red-50 px-4 py-3'
):
ui.label('本地个人数据').classes('text-sm font-bold text-red-900')
ui.label(
'历史、收藏、当前工作区和搜索配置只保存在这个浏览器中。'
).classes('text-xs text-red-800 mt-1')
ui.button(
'删除所有本地个人数据',
icon='delete_forever',
on_click=controller._confirm_delete_all_personal_data,
).props('outline color=negative no-caps').classes('mt-3')
with ui.row().classes('w-full items-center justify-between gap-3 flex-wrap'):
ui.label('DanbooruSearch 将持续免费开放。').classes('text-xs text-slate-500')
ui.button(
sponsor_notice_text,
icon='volunteer_activism',
on_click=controller.sponsor_dialog.open,
).props('flat dense no-caps color=grey-7').classes('text-xs')
def open_history_dialog(controller: Any) -> None:
"""显示本地搜索历史及其恢复操作。"""
with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl max-h-[85vh]'):
with ui.row().classes('w-full items-center justify-between'):
ui.label('搜索历史').classes('text-lg font-bold')
with ui.row().classes('gap-2'):
if controller.search_history.get('items'):
ui.button(
'清空全部',
icon='delete_sweep',
on_click=lambda: controller._confirm_clear_history(dialog),
).props('flat dense color=red-7')
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
with ui.scroll_area().classes('w-full h-[65vh]'):
items = controller.search_history.get('items', [])
if not items:
ui.label('暂无搜索历史').classes('text-sm text-gray-400 p-6')
for item in items:
with ui.card().classes('w-full mb-2 p-3 border border-gray-200 shadow-none'):
with ui.row().classes('w-full items-start justify-between gap-3'):
with ui.column().classes('gap-1 flex-grow min-w-0'):
ui.label(item['query']).classes('font-medium text-gray-800 break-all')
selected_count = len(item['workspace'].get('selected', []))
ui.label(
f"{format_history_time(item.get('searched_at'))} · "
f"工作区内有 {selected_count} 个标签"
).classes('text-xs text-gray-400')
ui.label(
format_history_settings(item.get('settings'))
).classes('text-xs text-gray-400')
with ui.row().classes('gap-1 flex-wrap justify-end'):
ui.button(
'重新搜索',
icon='search',
on_click=lambda value=item, current=dialog: controller._history_research(
value, current
),
).props('flat dense color=primary')
ui.button(
'恢复工作区',
icon='restore',
on_click=lambda value=item, current=dialog: controller._history_restore(
value, current
),
).props('flat dense color=teal-7')
ui.button(
'追加查询',
icon='playlist_add',
on_click=lambda value=item, current=dialog: controller._history_append(
value, current
),
).props('flat dense color=purple-7')
ui.button(
icon='delete_outline',
on_click=lambda value=item, current=dialog: controller._delete_history_entry(
value, current
),
).props('flat round dense color=red-6')
dialog.open()
def open_backup_dialog(controller: Any, *, description: str) -> None:
"""显示备份导出与二次确认导入入口。"""
with ui.dialog() as dialog, ui.card().classes('w-full max-w-2xl'):
ui.label('本地数据备份与迁移').classes('text-lg font-bold')
ui.label(description).classes('text-sm text-orange-700 bg-orange-50 rounded p-3')
ui.button('导出完整 JSON', icon='download', on_click=controller._export_backup).props(
'unelevated color=primary'
).classes('w-full')
ui.separator()
import_mode = ui.select(
{
'merge': '合并:保留当前权重和配置',
'overwrite': '覆盖:使用备份中的全部数据',
'favorites_only': '只导入收藏',
},
value='merge',
label='导入方式',
).props('outlined').classes('w-full')
pending_import = {'raw': None, 'name': ''}
pending_label = ui.label(
'选择文件后,请检查文件名和导入方式,再点击“确认导入”。'
).classes('text-sm text-gray-500')
confirm_import_button = None
async def handle_upload(event: Any) -> None:
try:
raw = await event.file.text('utf-8')
json.loads(raw)
pending_import['raw'] = raw
pending_import['name'] = event.file.name
pending_label.text = f'已选择:{event.file.name};尚未执行导入'
confirm_import_button.enable()
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
pending_import['raw'] = None
pending_import['name'] = ''
pending_label.text = '文件读取失败,请重新选择有效的 JSON 文件。'
confirm_import_button.disable()
ui.notify(f'文件读取失败:{exc}', type='negative', timeout=5000)
except Exception as exc:
pending_import['raw'] = None
pending_import['name'] = ''
pending_label.text = '文件读取失败,请重新选择。'
confirm_import_button.disable()
print(f'[UI] JSON 文件读取异常: {exc}', flush=True)
ui.notify('文件读取失败,请检查文件格式', type='negative')
async def confirm_import() -> None:
raw = pending_import.get('raw')
if not isinstance(raw, str):
ui.notify('请先选择 JSON 文件', type='warning')
return
confirm_import_button.disable()
try:
await controller._import_backup_text(raw, import_mode.value)
dialog.close()
except (ValueError,) as exc:
ui.notify(f'导入失败:{exc}', type='negative', timeout=5000)
confirm_import_button.enable()
except Exception as exc:
print(f'[UI] JSON 导入异常: {exc}', flush=True)
ui.notify('导入失败,请检查文件格式', type='negative')
confirm_import_button.enable()
ui.upload(
label='选择 JSON 文件',
on_upload=handle_upload,
auto_upload=True,
max_file_size=12_000_000,
on_rejected=lambda: ui.notify('文件过大,仅支持 12 MB 以内的 JSON', type='warning'),
).props('accept=.json').classes('w-full')
with ui.row().classes('w-full justify-end gap-2'):
ui.button('关闭', on_click=dialog.close).props('flat')
confirm_import_button = ui.button(
'确认导入', icon='check', on_click=confirm_import,
).props('unelevated color=primary')
confirm_import_button.disable()
dialog.open()
def open_favorites_dialog(controller: Any) -> None:
"""显示收藏列表与加载、合并、导出等操作入口。"""
with ui.dialog() as dialog, ui.card().classes('w-full max-w-5xl max-h-[88vh]'):
with ui.row().classes('w-full items-center justify-between'):
ui.label('收藏').classes('text-lg font-bold')
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
with ui.scroll_area().classes('w-full h-[70vh]'):
items = controller.favorites.get('items', [])
if not items:
ui.label('暂无收藏').classes('text-sm text-gray-400 p-6')
for item in items:
with ui.card().classes('w-full mb-2 p-3 border border-amber-100 shadow-none'):
with ui.row().classes('w-full items-start justify-between gap-3'):
with ui.column().classes('gap-1 flex-grow min-w-0'):
ui.label(item['name']).classes('font-bold text-gray-800')
ui.label(
f"{len(item['selected'])} 个标签 · {item['prompt_format'].upper()} · "
f"更新于 {item['updated_at']}"
).classes('text-xs text-gray-400')
if item.get('source_query'):
ui.label(f"来源:{item['source_query']}").classes(
'text-xs text-gray-500 break-all'
)
if item.get('notes'):
ui.label(item['notes']).classes('text-xs text-gray-600')
with ui.row().classes('gap-1 flex-wrap justify-end max-w-xl'):
ui.button(
'替换载入', icon='file_open',
on_click=lambda value=item, current=dialog: controller._load_favorite(
value, False, current
),
).props('flat dense color=teal-7')
ui.button(
'合并', icon='merge',
on_click=lambda value=item, current=dialog: controller._load_favorite(
value, True, current
),
).props('flat dense color=primary')
ui.button(
'复制', icon='content_copy',
on_click=lambda value=item: controller._copy_favorite(value),
).props('flat dense color=grey-7')
ui.button(
'重命名', icon='edit',
on_click=lambda value=item, current=dialog: controller._rename_favorite(
value, current
),
).props('flat dense color=grey-7')
ui.button(
'覆盖', icon='save',
on_click=lambda value=item, current=dialog: controller._overwrite_favorite(
value, current
),
).props('flat dense color=amber-8')
ui.button(
'导出', icon='download',
on_click=lambda value=item: controller._export_favorite(value),
).props('flat dense color=purple-7')
ui.button(
icon='delete_outline',
on_click=lambda value=item, current=dialog: controller._confirm_delete_favorite(
value, current
),
).props('flat round dense color=red-6')
dialog.open()
def open_save_favorite_dialog(controller: Any) -> None:
"""显示保存当前工作区为收藏的表单。"""
if not controller._get_selected_tags():
ui.notify('当前工作区没有可收藏的标签', type='warning')
return
with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
ui.label('保存当前工作区为收藏').classes('text-lg font-bold')
name_input = ui.input('收藏名称').props('outlined maxlength=80').classes('w-full')
notes_input = ui.textarea('备注(可选)').props(
'outlined autogrow maxlength=500'
).classes('w-full')
def save() -> None:
name = (name_input.value or '').strip()
if not name:
ui.notify('请输入收藏名称', type='warning')
return
if any(item['name'] == name for item in controller.favorites.get('items', [])):
ui.notify('已存在同名收藏,请在收藏列表中使用“覆盖”', type='warning')
return
if controller._save_current_workspace_as_favorite(
name, (notes_input.value or '').strip()
):
dialog.close()
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat')
ui.button('保存', icon='bookmark_add', on_click=save).props(
'unelevated color=teal-7'
)
dialog.open()
def open_prompt_import_dialog(controller: Any, *, description: str) -> None:
"""显示 Prompt 粘贴、解析和导入入口。"""
with ui.dialog() as dialog, ui.card().classes('w-full max-w-3xl'):
ui.label('导入 Prompt').classes('text-lg font-bold')
ui.label(description).classes('text-sm text-gray-600')
prompt_input = ui.textarea(
label='粘贴 Prompt',
placeholder='1girl, (white_serafuku:1.2), {rain}, @artist_name',
).props('outlined autogrow maxlength=20000').classes('w-full min-h-48')
import_button = None
async def submit_import() -> None:
text = str(prompt_input.value or '').strip()
if not text:
ui.notify('请先粘贴 Prompt', type='warning')
return
import_button.disable()
try:
result = await controller._resolve_prompt_import_text(text)
if result.parsed_count == 0:
ui.notify('没有解析到可导入内容', type='warning')
import_button.enable()
return
added_count, duplicate_count = controller._apply_prompt_import_result(result)
dialog.close()
controller._show_prompt_import_summary(result, added_count, duplicate_count)
except Exception as exc:
print(f'[UI] Prompt 导入异常: {exc}', flush=True)
ui.notify('Prompt 导入失败,请检查输入内容', type='negative')
import_button.enable()
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat')
import_button = ui.button(
'解析并导入', icon='playlist_add', on_click=submit_import,
).props('unelevated color=purple-7')
dialog.open()
def confirm_clear_history(controller: Any, parent_dialog: Any) -> None:
"""确认清空历史,明确不影响收藏与当前工作区。"""
with ui.dialog() as confirm, ui.card():
ui.label('确定清空全部搜索历史吗?收藏和当前工作区不会受到影响。')
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=confirm.close).props('flat')
def clear() -> None:
controller._clear_all_history()
confirm.close()
parent_dialog.close()
ui.notify('搜索历史已清空', type='positive')
ui.button('清空', on_click=clear).props('unelevated color=red-7')
confirm.open()
def confirm_delete_favorite(controller: Any, favorite: dict, parent_dialog: Any) -> None:
"""确认删除指定收藏。"""
with ui.dialog() as dialog, ui.card():
ui.label(f"确定删除收藏“{favorite['name']}”吗?")
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat')
def delete() -> None:
if not controller._delete_favorite(favorite):
return
dialog.close()
parent_dialog.close()
ui.notify('收藏已删除', type='positive')
ui.button('删除', on_click=delete).props('unelevated color=red-7')
dialog.open()
def confirm_delete_all_personal_data(controller: Any, storage_keys: list[str]) -> None:
"""确认删除当前浏览器保存的个人数据,不涉及服务器或导出的备份文件。"""
with ui.dialog() as confirm, ui.card().classes('w-full max-w-lg'):
with ui.row().classes('items-center gap-2'):
ui.icon('warning', color='negative')
ui.label('删除所有本地个人数据?').classes('text-lg font-bold text-red-900')
ui.label(
'将删除当前浏览器中保存的搜索配置、搜索输入、工作区、已选标签、权重、'
'搜索历史、收藏以及旧版或损坏数据备份。'
).classes('text-sm text-slate-700 leading-relaxed')
ui.label(
'此操作无法撤销,但不会删除你已经下载到电脑上的 JSON 备份,也不会修改服务器端匿名聚合统计。'
).classes('text-xs text-red-700 bg-red-50 rounded p-3')
async def delete_all_personal_data() -> None:
delete_button.disable()
keys_json = json.dumps(storage_keys, ensure_ascii=False)
try:
await ui.run_javascript(
f"""(() => {{
const keys = {keys_json};
keys.forEach((key) => localStorage.removeItem(key));
return keys.length;
}})()""",
timeout=5.0,
)
confirm.close()
controller.help_dialog.close()
ui.notify('本地个人数据已删除,页面即将刷新', type='positive', timeout=2000)
await ui.run_javascript(
"(() => { setTimeout(() => window.location.reload(), 500); return true; })()",
timeout=5.0,
)
except Exception as exc:
print(f'[UI] 删除本地个人数据失败: {exc}', flush=True)
delete_button.enable()
ui.notify('删除失败,请稍后重试', type='negative')
with ui.row().classes('w-full justify-end gap-2 mt-2'):
ui.button('取消', on_click=confirm.close).props('flat color=grey-7')
delete_button = ui.button(
'确认删除',
icon='delete_forever',
on_click=delete_all_personal_data,
).props('unelevated color=negative no-caps')
confirm.open()
def open_rename_favorite_dialog(
controller: Any,
favorite: dict,
parent_dialog: Any,
) -> None:
with ui.dialog() as dialog, ui.card().classes('w-full max-w-md'):
ui.label('重命名收藏').classes('font-bold')
name_input = ui.input('新名称', value=favorite['name']).props(
'outlined maxlength=80'
).classes('w-full')
def rename() -> None:
error = controller._rename_favorite_to(
favorite,
str(name_input.value or '').strip(),
)
if error:
ui.notify(error, type='warning')
return
dialog.close()
parent_dialog.close()
controller._open_favorites_dialog()
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat')
ui.button('保存', on_click=rename).props('unelevated color=primary')
dialog.open()
def open_search_feedback_dialog(
controller: Any,
*,
query: str,
privacy_text: str,
) -> None:
with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
ui.label('反馈搜索问题').classes('text-base font-bold text-gray-800')
ui.label(f'当前搜索词:{query}').classes('text-sm text-gray-600')
ui.label(privacy_text).classes(
'text-xs text-slate-500 bg-slate-50 rounded p-2'
)
detail_input = ui.textarea(
label='具体问题(可选)',
placeholder='例如:结果偏题、缺少某个关键标签、召回了不相关角色/作品...',
).props('outlined autogrow maxlength=500 counter').classes('w-full')
async def submit_feedback() -> None:
submit_button.disable()
try:
await controller._submit_search_feedback(
query,
str(detail_input.value or '').strip(),
)
if controller.bad_case_btn is not None:
controller.bad_case_btn.disable()
dialog.close()
ui.notify('感谢反馈!我们会持续优化。', type='positive', timeout=3000)
except Exception as exc:
print(f'[UI] bad_case 记录异常: {exc}')
submit_button.enable()
ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000)
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat color=grey-7')
submit_button = ui.button(
'提交反馈', on_click=submit_feedback,
).props('unelevated color=primary')
dialog.open()
def open_translation_feedback_dialog(
controller: Any,
*,
row: dict,
privacy_text: str,
) -> None:
tag = str(row.get('tag') or '').strip()
current_cn_name = str(row.get('cn_name') or '').strip()
current_cn_first = current_cn_name.split(',', 1)[0].strip()
with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
ui.label('反馈翻译错误').classes('text-base font-bold text-gray-800')
ui.label(f'词条:{tag}').classes('text-sm font-mono text-gray-700')
ui.label(
f'当前中文名:{current_cn_first or current_cn_name or "(空)"}'
).classes('text-sm text-gray-600')
ui.label(privacy_text).classes(
'text-xs text-slate-500 bg-slate-50 rounded p-2'
)
suggested_input = ui.input(
label='建议中文名(可选)',
placeholder='如果有更合适的译名,可以填在这里',
).props('outlined maxlength=120 counter').classes('w-full')
detail_input = ui.textarea(
label='问题说明(可选)',
placeholder='例如:含义不准确、作品/角色名误译、中文名缺失...',
).props('outlined autogrow maxlength=500 counter').classes('w-full')
async def submit_feedback() -> None:
submit_button.disable()
try:
await controller._submit_translation_feedback(
row,
str(suggested_input.value or '').strip(),
str(detail_input.value or '').strip(),
)
dialog.close()
ui.notify('感谢反馈!这条翻译问题已记录。', type='positive', timeout=3000)
except Exception as exc:
print(f'[UI] translation_error 记录异常: {exc}')
submit_button.enable()
ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000)
with ui.row().classes('w-full justify-end gap-2'):
ui.button('取消', on_click=dialog.close).props('flat color=grey-7')
submit_button = ui.button(
'提交反馈', on_click=submit_feedback,
).props('unelevated color=primary')
dialog.open()