"""推荐区域的 NiceGUI 容器与分页控件渲染。""" from typing import Any from nicegui import ui from webui.constants import ( ARTIST_REC_LIMIT, ARTIST_REC_PAGE_SIZE, GROUP_RENDER_TAG_LIMIT, RELATED_REC_PAGE_SIZE, ) from core.workspace_insights import ( artist_candidate_reason, related_candidate_reason, tag_group_candidate_reason, ) from webui.helpers import ( group_names_key, group_scroll_dom_id, limit_group_render_tags, should_group_start_expanded, ) from webui.recommendations import page_count, page_items def render_related_list(controller: Any, related: list, show_nsfw: bool) -> None: """保存关联推荐快照,构建分页控件并渲染第一页。""" if controller.related_list_container is None or controller.related_pagination is None: return controller.related_list_container.clear() controller.related_pagination.clear() controller._related_checkboxes.clear() controller._related_page = 1 controller._related_page_label = None controller._related_prev_button = None controller._related_next_button = None controller._related_results = [ item for item in related if not (item.nsfw == '1' and not show_nsfw) ] controller._related_show_nsfw = show_nsfw controller._related_page_count = page_count( len(controller._related_results), RELATED_REC_PAGE_SIZE ) if not controller._related_results: controller._render_related_page() controller._replay_motion( 'danbooru-related-recommendations', 'motion-recommendation-enter-right' ) return if controller._related_page_count > 1: with controller.related_pagination: with ui.row().classes('w-full items-center justify-center gap-2 px-3 py-2'): controller._related_prev_button = ui.button( '‹', on_click=lambda: controller._set_related_page( controller._related_page - 1 ), ).props('flat dense round color=grey-7') controller._related_page_label = ui.label().classes( 'text-xs text-gray-600 min-w-12 text-center' ) controller._related_next_button = ui.button( '›', on_click=lambda: controller._set_related_page( controller._related_page + 1 ), ).props('flat dense round color=grey-7') controller._set_related_page(1) def render_artist_recommendations( controller: Any, artist_results: list, top_tags: dict | None = None, show_nsfw: bool = True, ) -> None: """保存画师推荐快照,构建分页控件并渲染第一页。""" if controller.artist_rec_list is None or controller.artist_rec_pagination is None: return controller.artist_rec_list.clear() controller.artist_rec_pagination.clear() controller._artist_rec_checkboxes.clear() controller._artist_rec_rows.clear() controller._artist_rec_page = 1 controller._artist_rec_page_label = None controller._artist_rec_prev_button = None controller._artist_rec_next_button = None controller._artist_rec_results = list(artist_results[:ARTIST_REC_LIMIT]) controller._artist_rec_top_tags = dict(top_tags or {}) controller._artist_rec_show_nsfw = show_nsfw controller._artist_rec_page_count = page_count( len(controller._artist_rec_results), ARTIST_REC_PAGE_SIZE ) controller._current_artist_rec_tags = { result.artist for result in controller._artist_rec_results } controller._artist_rec_sources = { result.artist: '、'.join(result.sources[:3]) for result in controller._artist_rec_results } if not controller._artist_rec_results: controller._render_artist_rec_page() controller._replay_motion( 'danbooru-artist-recommendations', 'motion-recommendation-enter-right' ) return if controller._artist_rec_page_count > 1: with controller.artist_rec_pagination: with ui.row().classes('w-full items-center justify-center gap-2 px-3 py-2'): controller._artist_rec_prev_button = ui.button( '‹', on_click=lambda: controller._set_artist_rec_page( controller._artist_rec_page - 1 ), ).props('flat dense round color=grey-7') controller._artist_rec_page_label = ui.label().classes( 'text-xs text-gray-600 min-w-12 text-center' ) controller._artist_rec_next_button = ui.button( '›', on_click=lambda: controller._set_artist_rec_page( controller._artist_rec_page + 1 ), ).props('flat dense round color=grey-7') controller._set_artist_rec_page(1) def render_related_page(controller: Any) -> None: """重建当前关联推荐页,节点数量固定不超过 10 条。""" controller.related_list_container.clear() controller._related_checkboxes.clear() if not controller._related_results: with controller.related_list_container: ui.label('暂无推荐').classes('text-sm text-gray-400 italic p-4') return selected_now = set(controller._get_selected_tags()) page_results = page_items( controller._related_results, controller._related_page, RELATED_REC_PAGE_SIZE, ) with controller.related_list_container: for r in page_results: tag = r.tag cn_first = r.cn_name.split(',')[0].strip() if r.cn_name else '' is_selected = tag in selected_now score_pct = f'+{r.cooc_score * 100:.0f}%' wiki_text = controller._lookup_tag_wiki(tag) sources_str = '、'.join( s.replace('tag_group:', '') for s in r.sources ) if r.sources else '—' CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'} cat_label = CAT_LABEL.get(r.category, '') tooltip_html = '' if wiki_text: prefix = f'[{cat_label}]' if cat_label else '' tooltip_html += f'
{prefix}{wiki_text}
' tooltip_html += ( f'
' f'{r.cn_name}
' f'共现: {r.cooc_count:,} 相关度: {r.cooc_score:.2f}
' f'来自选中: {sources_str}' f'
' ) # 行背景色按分类区分 CAT_BG = { 'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝 'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿 'Copyright': 'background-color: rgba(168,85,247,0.06);', # 淡紫 } row_bg = CAT_BG.get(r.category, '') # 整行容器,tooltip 挂在行上 with ui.row().classes( 'w-full flex-nowrap items-stretch gap-0 overflow-hidden ' 'related-item recommendation-row' ).style(row_bg): # 整行 wiki tooltip if tooltip_html: with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;') # Checkbox 单元格 with ui.element('div').classes( 'recommendation-cell flex-none justify-center px-2 py-2' ): cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=tag: controller._on_related_checkbox_change(t, e.value) ).props('dense').classes('flex-none') controller._related_checkboxes[tag] = cb # 标签名(可点击跳转)+ 中文名 with ui.element('div').classes( 'recommendation-cell ' 'flex-1 min-w-0 overflow-hidden px-3 py-2' ): with ui.column().classes('w-full gap-0 min-w-0 overflow-hidden'): with ui.row().classes('w-full flex-nowrap items-center gap-1 min-w-0 overflow-hidden'): link = ui.link( tag, f'https://danbooru.donmai.us/wiki_pages/{tag}', new_tab=True ).classes( 'tag-link text-primary font-bold text-xs flex-1 min-w-0 truncate' ) link.on('click', controller._mark_interaction) if r.sources and r.sources[0].startswith('tag_group:'): group_display = r.sources[0].replace('tag_group:', '') ui.label(group_display).classes( 'text-xs text-orange-500 font-bold bg-orange-50 px-1 rounded' ) if cn_first: ui.label(cn_first).classes('w-full text-xs text-gray-500 truncate') ui.label( related_candidate_reason(r.sources) ).classes('w-full text-xs text-slate-500 truncate') # 关联分数单元格 with ui.element('div').classes( 'recommendation-cell ' 'flex-none justify-end min-w-16 px-3 py-2' ): score_color = 'green' if r.cooc_score > 0.6 else ('teal' if r.cooc_score > 0.3 else 'grey') ui.label(score_pct).classes( f'text-sm font-bold text-{score_color}-600 whitespace-nowrap' ) def render_artist_page(controller: Any) -> None: """重建当前画师页,节点数量固定不超过 ARTIST_REC_PAGE_SIZE。""" if controller.artist_rec_list is None: return controller.artist_rec_list.clear() controller._artist_rec_checkboxes.clear() controller._artist_rec_rows.clear() if not controller._artist_rec_results: with controller.artist_rec_list: ui.label('暂无推荐画师').classes('text-sm text-gray-400 italic p-4') return selected_now = set(controller._get_selected_tags()) page_results = page_items( controller._artist_rec_results, controller._artist_rec_page, ARTIST_REC_PAGE_SIZE, ) with controller.artist_rec_list: for r in page_results: artist = r.artist is_selected = artist in selected_now # 归一化:除以命中标签数,cap 到 100% normalized = min(r.score / max(r.hit_count, 1), 1.0) score_pct = f'+{normalized * 100:.0f}%' reason = artist_candidate_reason(r.sources) post_str = f'{r.post_count:,}' if r.post_count else '—' # tooltip:画师擅长标签 tag_list = controller._artist_rec_top_tags.get(artist, []) tooltip_html = f'
{artist}
这位画师经常画:
' if tag_list: for t in tag_list[:10]: tooltip_html += f' · {t}
' else: tooltip_html += ' (无数据)' tooltip_html += '
' with ui.row().classes( 'w-full flex-nowrap items-stretch gap-0 overflow-hidden ' 'related-item recommendation-row' ).style('background: rgba(244,114,182,0.04);') as row: controller._artist_rec_rows.append(row) # tooltip with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:380px;') # Checkbox 单元格 with ui.element('div').classes( 'recommendation-cell flex-none justify-center px-2 py-2' ): cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=artist: controller._on_artist_rec_checkbox_change(t, e.value) ).props('dense') controller._artist_rec_checkboxes[artist] = cb # 画师名 + 信息 with ui.element('div').classes( 'recommendation-cell ' 'flex-grow min-w-0 overflow-hidden px-3 py-2' ): with ui.column().classes('w-full gap-0 min-w-0'): ui.link( artist, f'https://danbooru.donmai.us/posts?tags={artist}', new_tab=True, ).classes('text-primary font-bold text-xs') ui.label(reason).classes('text-xs text-slate-500') ui.label(f'作品 {post_str}').classes('text-xs text-gray-400') # 分值单元格 with ui.element('div').classes( 'recommendation-cell ' 'flex-none justify-end min-w-16 px-3 py-2' ): score_color = 'green' if normalized > 0.6 else ('teal' if normalized > 0.3 else 'grey') ui.label(score_pct).classes( f'text-sm font-bold text-{score_color}-600 whitespace-nowrap' ) def render_group_expansion(controller: Any, group_data: list, selected_tags: list[str], show_nsfw: bool) -> None: """渲染 Group 同类扩展区域。""" if controller.group_expansion_container is None: return controller.group_expansion_container.clear() controller._group_checkboxes.clear() controller._group_candidate_sources.clear() group_key = group_names_key(group_data) if group_key != controller._group_render_key: controller._group_render_key = group_key controller._group_render_limits.clear() controller._group_expanded_names.clear() controller._group_scroll_positions.clear() if not group_data: with controller.group_expansion_container: ui.label('已选标签无分组信息').classes('text-sm text-gray-400 italic p-2') controller._replay_motion('danbooru-group-expansion', 'motion-refresh-enter') return # 行背景色按分类区分(与关联推荐一致) CAT_BG = { 'General': 'background-color: rgba(59,130,246,0.06);', 'Character': 'background-color: rgba(34,197,94,0.06);', 'Copyright': 'background-color: rgba(168,85,247,0.06);', } CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'} selected_now = set(controller._get_selected_tags()) with controller.group_expansion_container: for group_info in group_data: group_name = group_info['group'] group_cn = group_info.get('group_cn_name', group_name.replace('tag_group:', '')) group_sources = list(group_info.get('sources') or []) group_reason = tag_group_candidate_reason(group_cn, group_sources) group_source_detail = group_cn if group_sources: group_source_detail += f";触发标签:{'、'.join(group_sources[:3])}" tags = group_info['tags'] visible_limit = controller._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT) visible_tags, hidden_count = limit_group_render_tags(tags, visible_limit) scroll_id = group_scroll_dom_id(group_name) expansion = ui.expansion( f'{group_cn} ({len(tags)} 个标签)', icon='label', value=should_group_start_expanded(group_name, controller._group_expanded_names), ).classes('w-full').props('dense') expansion.on( 'update:model-value', lambda e, g=group_name: controller._on_group_expansion_change(g, e), ) with expansion: with ui.element('div').props( f'id="{scroll_id}" data-danbooru-group-scroll="1"' ).classes('w-full grid grid-cols-2 gap-1 p-1').style('max-height: 600px; overflow-y: auto;'): for t in visible_tags: tag = t['tag'] controller._group_candidate_sources.setdefault(tag, group_source_detail) cn_first = t['cn_name'].split(',')[0].strip() if t['cn_name'] else '' cn_full = t.get('cn_name', '') cat = t['category'] wiki_text = str(t.get('wiki', '')) row_bg = CAT_BG.get(cat, '') is_selected = tag in selected_now cat_label = CAT_LABEL.get(cat, '') tooltip_html = '' if wiki_text: prefix = f'[{cat_label}]' if cat_label else '' tooltip_html += f'
{prefix}{wiki_text}
' if cn_full: tooltip_html += f'
{cn_full}
' with ui.row().classes( 'w-full min-w-0 flex-nowrap items-center gap-1.5 px-2 py-1.5 ' 'rounded overflow-hidden related-item' ).style(row_bg): if tooltip_html: with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;') # 复选框 cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=tag: controller._on_group_checkbox_change(t, e.value), ).props('dense').classes('flex-none') controller._group_checkboxes[tag] = cb # 标签名 + 中文名(与关联推荐对齐方式一致) with ui.column().classes('flex-1 gap-0 min-w-0 overflow-hidden'): link = ui.link( tag, f'https://danbooru.donmai.us/wiki_pages/{tag}', new_tab=True, ).classes( 'tag-link w-full min-w-0 text-primary font-bold text-xs truncate' ) if cn_first: ui.label(cn_first).classes( 'w-full text-xs text-gray-500 truncate' ) ui.label(group_reason).classes( 'w-full text-xs text-slate-500 truncate' ) # 热度 count = t['post_count'] if count > 0: if count >= 10000: count_str = f'{count/1000:.0f}k' elif count >= 1000: count_str = f'{count/1000:.1f}k' else: count_str = str(count) ui.label(count_str).classes( 'flex-none ml-auto self-center text-sm font-bold ' 'text-grey-600 whitespace-nowrap' ) if hidden_count > 0: async def _load_more( g=group_name, total=len(tags), gd=group_data, st=list(selected_tags), sn=show_nsfw, ): await controller._load_more_group_tags(g, total, gd, st, sn) ui.button( f'加载更多(剩余 {hidden_count} 个)', icon='expand_more', on_click=_load_more, ).props('dense flat color=primary').classes('col-span-2 text-xs') controller._restore_group_scroll_positions() controller._replay_motion('danbooru-group-expansion', 'motion-refresh-enter')