"""通用帮助、历史与备份弹窗视图。""" 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()