用Kivy制作手机相片分类局域网传送工具,还能传输数据库文件

一开始我只想做一个相册浏览器,后来功能越来越多了。

bash 复制代码
[app]

# (str) Title of your application
title = PhotoBrowserApp
python_version = 3.11.9
# p4a.extra_args = --python-version=3.11.9
# (str) Package name
package.name = PhotoBrowserApp

# (str) Package domain (needed for android/ios packaging)
package.domain = org.test

# (str) Source code where the main.py live
source.dir = .

# (list) Source files to include (let empty to include all the files)
source.include_exts = py,png,jpg,kv,atlas

# (list) List of inclusions using pattern matching
#source.include_patterns = assets/*,images/*.png

# (list) Source files to exclude (let empty to not exclude anything)
#source.exclude_exts = spec

# (list) List of directory to exclude (let empty to not exclude anything)
#source.exclude_dirs = tests, bin, venv

# (list) List of exclusions using pattern matching
# Do not prefix with './'
#source.exclude_patterns = license,images/*/*.jpg

# (str) Application versioning (method 1)
version = 0.1

# (str) Application versioning (method 2)
# version.regex = __version__ = ['"](.*)['"]
# version.filename = %(source.dir)s/main.py

# (list) Application requirements
# comma separated e.g. requirements = sqlite3,kivy
requirements = python3,kivy,pillow

# (str) Custom source folders for requirements
# Sets custom source for any requirements with recipes
# requirements.source.kivy = ../../kivy

# (str) Presplash of the application
#presplash.filename = %(source.dir)s/data/presplash.png

# (str) Icon of the application
#icon.filename = %(source.dir)s/data/icon.png

# (list) Supported orientations
# Valid options are: landscape, portrait, portrait-reverse or landscape-reverse
orientation = portrait

# (list) List of service to declare
#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY

#
# OSX Specific
#

#
# author = © Copyright Info

# change the major version of python used by the app
osx.python_version = 3

# Kivy version to use
osx.kivy_version = 1.9.1

#
# Android specific
#

# (bool) Indicate if the application should be fullscreen or not
fullscreen = 0

# (string) Presplash background color (for android toolchain)
# Supported formats are: #RRGGBB #AARRGGBB or one of the following names:
# red, blue, green, black, white, gray, cyan, magenta, yellow, lightgray,
# darkgray, grey, lightgrey, darkgrey, aqua, fuchsia, lime, maroon, navy,
# olive, purple, silver, teal.
#android.presplash_color = #FFFFFF

# (string) Presplash animation using Lottie format.
# see https://lottiefiles.com/ for examples and https://airbnb.design/lottie/
# for general documentation.
# Lottie files can be created using various tools, like Adobe After Effect or Synfig.
#android.presplash_lottie = "path/to/lottie/file.json"

# (str) Adaptive icon of the application (used if Android API level is 26+ at runtime)
#icon.adaptive_foreground.filename = %(source.dir)s/data/icon_fg.png
#icon.adaptive_background.filename = %(source.dir)s/data/icon_bg.png

# (list) Permissions
# (See https://python-for-android.readthedocs.io/en/latest/buildoptions/#build-options-1 for all the supported syntaxes and properties)
android.permissions =  INTERNET, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE, MANAGE_EXTERNAL_STORAGE
#android.permissions = android.permission.INTERNET, (name=android.permission.WRITE_EXTERNAL_STORAGE;maxSdkVersion=18)

# (list) features (adds uses-feature -tags to manifest)
#android.features = android.hardware.usb.host

# (int) Target Android API, should be as high as possible.
android.api = 33

# (int) Minimum API your APK / AAB will support.
android.minapi = 21

# (int) Android SDK version to use
#android.sdk = 20

# (str) Android NDK version to use
android.ndk = 25b

# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi.
#android.ndk_api = 21

# (bool) Use --private data storage (True) or --dir public storage (False)
#android.private_storage = True

# (str) Android NDK directory (if empty, it will be automatically downloaded.)
#android.ndk_path =

# (str) Android SDK directory (if empty, it will be automatically downloaded.)
#android.sdk_path =

# (str) ANT directory (if empty, it will be automatically downloaded.)
#android.ant_path =

# (bool) If True, then skip trying to update the Android sdk
# This can be useful to avoid excess Internet downloads or save time
# when an update is due and you just want to test/build your package
# android.skip_update = False

# (bool) If True, then automatically accept SDK license
# agreements. This is intended for automation only. If set to False,
# the default, you will be shown the license when first running
# buildozer.
# android.accept_sdk_license = False

# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.kivy.android.PythonActivity

# (str) Full name including package path of the Java class that implements Android Activity
# use that parameter together with android.entrypoint to set custom Java class instead of PythonActivity
#android.activity_class_name = org.kivy.android.PythonActivity

# (str) Extra xml to write directly inside the <manifest> element of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML code
#android.extra_manifest_xml = ./src/android/extra_manifest.xml

# (str) Extra xml to write directly inside the <manifest><application> tag of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML arguments:
#android.extra_manifest_application_arguments = ./src/android/extra_manifest_application_arguments.xml

# (str) Full name including package path of the Java class that implements Python Service
# use that parameter to set custom Java class which extends PythonService
#android.service_class_name = org.kivy.android.PythonService

# (str) Android app theme, default is ok for Kivy-based app
# android.apptheme = "@android:style/Theme.NoTitleBar"

# (list) Pattern to whitelist for the whole project
#android.whitelist =

# (str) Path to a custom whitelist file
#android.whitelist_src =

# (str) Path to a custom blacklist file
#android.blacklist_src =

# (list) List of Java .jar files to add to the libs so that pyjnius can access
# their classes. Don't add jars that you do not need, since extra jars can slow
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar

# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
#android.add_src =

# (list) Android AAR archives to add
#android.add_aars =

# (list) Put these files or directories in the apk assets directory.
# Either form may be used, and assets need not be in 'source.include_exts'.
# 1) android.add_assets = source_asset_relative_path
# 2) android.add_assets = source_asset_path:destination_asset_relative_path
#android.add_assets =

# (list) Put these files or directories in the apk res directory.
# The option may be used in three ways, the value may contain one or zero ':'
# Some examples:
# 1) A file to add to resources, legal resource names contain ['a-z','0-9','_']
# android.add_resources = my_icons/all-inclusive.png:drawable/all_inclusive.png
# 2) A directory, here  'legal_icons' must contain resources of one kind
# android.add_resources = legal_icons:drawable
# 3) A directory, here 'legal_resources' must contain one or more directories, 
# each of a resource kind:  drawable, xml, etc...
# android.add_resources = legal_resources
#android.add_resources =

# (list) Gradle dependencies to add
#android.gradle_dependencies =

# (bool) Enable AndroidX support. Enable when 'android.gradle_dependencies'
# contains an 'androidx' package, or any package from Kotlin source.
# android.enable_androidx requires android.api >= 28
android.enable_androidx = True

# (list) add java compile options
# this can for example be necessary when importing certain java libraries using the 'android.gradle_dependencies' option
# see https://developer.android.com/studio/write/java8-support for further information
# android.add_compile_options = "sourceCompatibility = 1.8", "targetCompatibility = 1.8"

# (list) Gradle repositories to add {can be necessary for some android.gradle_dependencies}
# please enclose in double quotes 
# e.g. android.gradle_repositories = "maven { url 'https://kotlin.bintray.com/ktor' }"
#android.add_gradle_repositories =

# (list) packaging options to add 
# see https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html
# can be necessary to solve conflicts in gradle_dependencies
# please enclose in double quotes 
# e.g. android.add_packaging_options = "exclude 'META-INF/common.kotlin_module'", "exclude 'META-INF/*.kotlin_module'"
#android.add_packaging_options =

# (list) Java classes to add as activities to the manifest.
#android.add_activities = com.example.ExampleActivity

# (str) OUYA Console category. Should be one of GAME or APP
# If you leave this blank, OUYA support will not be enabled
#android.ouya.category = GAME

# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png

# (str) XML file to include as an intent filters in <activity> tag
#android.manifest.intent_filters =

# (list) Copy these files to src/main/res/xml/ (used for example with intent-filters)
#android.res_xml = PATH_TO_FILE,

# (str) launchMode to set for the main activity
#android.manifest.launch_mode = standard

# (str) screenOrientation to set for the main activity.
# Valid values can be found at https://developer.android.com/guide/topics/manifest/activity-element
#android.manifest.orientation = fullSensor

# (list) Android additional libraries to copy into libs/armeabi
#android.add_libs_armeabi = libs/android/*.so
#android.add_libs_armeabi_v7a = libs/android-v7/*.so
#android.add_libs_arm64_v8a = libs/android-v8/*.so
#android.add_libs_x86 = libs/android-x86/*.so
#android.add_libs_mips = libs/android-mips/*.so

# (bool) Indicate whether the screen should stay on
# Don't forget to add the WAKE_LOCK permission if you set this to True
#android.wakelock = False

# (list) Android application meta-data to set (key=value format)
#android.meta_data =

# (list) Android library project to add (will be added in the
# project.properties automatically.)
#android.library_references =

# (list) Android shared libraries which will be added to AndroidManifest.xml using <uses-library> tag
#android.uses_library =

# (str) Android logcat filters to use
#android.logcat_filters = *:S python:D

# (bool) Android logcat only display log for activity's pid
#android.logcat_pid_only = False

# (str) Android additional adb arguments
#android.adb_args = -H host.docker.internal

# (bool) Copy library instead of making a libpymodules.so
#android.copy_libs = 1

# (list) The Android archs to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
# In past, was `android.arch` as we weren't supporting builds for multiple archs at the same time.
android.archs = arm64-v8a, armeabi-v7a

# (int) overrides automatic versionCode computation (used in build.gradle)
# this is not the same as app version and should only be edited if you know what you're doing
# android.numeric_version = 1

# (bool) enables Android auto backup feature (Android API >=23)
android.allow_backup = True

# (str) XML file for custom backup rules (see official auto backup documentation)
# android.backup_rules =

# (str) If you need to insert variables into your AndroidManifest.xml file,
# you can do so with the manifestPlaceholders property.
# This property takes a map of key-value pairs. (via a string)
# Usage example : android.manifest_placeholders = [myCustomUrl:\"org.kivy.customurl\"]
# android.manifest_placeholders = [:]

# (bool) Skip byte compile for .py files
# android.no-byte-compile-python = False

# (str) The format used to package the app for release mode (aab or apk or aar).
# android.release_artifact = aab

# (str) The format used to package the app for debug mode (apk or aar).
# android.debug_artifact = apk

#
# Python for android (p4a) specific
#

# (str) python-for-android URL to use for checkout
#p4a.url =

# (str) python-for-android fork to use in case if p4a.url is not specified, defaults to upstream (kivy)
#p4a.fork = kivy

# (str) python-for-android branch to use, defaults to master
#p4a.branch = master

# (str) python-for-android specific commit to use, defaults to HEAD, must be within p4a.branch
#p4a.commit = HEAD

# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
#p4a.source_dir =

# (str) The directory in which python-for-android should look for your own build recipes (if any)
#p4a.local_recipes =

# (str) Filename to the hook for p4a
#p4a.hook =

# (str) Bootstrap to use for android builds
# p4a.bootstrap = sdl2

# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
#p4a.port =

# Control passing the --use-setup-py vs --ignore-setup-py to p4a
# "in the future" --use-setup-py is going to be the default behaviour in p4a, right now it is not
# Setting this to false will pass --ignore-setup-py, true will pass --use-setup-py
# NOTE: this is general setuptools integration, having pyproject.toml is enough, no need to generate
# setup.py if you're using Poetry, but you need to add "toml" to source.include_exts.
#p4a.setup_py = false

# (str) extra command line arguments to pass when invoking pythonforandroid.toolchain
#p4a.extra_args =



#
# iOS specific
#

# (str) Path to a custom kivy-ios folder
#ios.kivy_ios_dir = ../kivy-ios
# Alternately, specify the URL and branch of a git checkout:
ios.kivy_ios_url = https://github.com/kivy/kivy-ios
ios.kivy_ios_branch = master

# Another platform dependency: ios-deploy
# Uncomment to use a custom checkout
#ios.ios_deploy_dir = ../ios_deploy
# Or specify URL and branch
ios.ios_deploy_url = https://github.com/phonegap/ios-deploy
ios.ios_deploy_branch = 1.10.0

# (bool) Whether or not to sign the code
ios.codesign.allowed = false

# (str) Name of the certificate to use for signing the debug version
# Get a list of available identities: buildozer ios list_identities
#ios.codesign.debug = "iPhone Developer: <lastname> <firstname> (<hexstring>)"

# (str) The development team to use for signing the debug version
#ios.codesign.development_team.debug = <hexstring>

# (str) Name of the certificate to use for signing the release version
#ios.codesign.release = %(ios.codesign.debug)s

# (str) The development team to use for signing the release version
#ios.codesign.development_team.release = <hexstring>

# (str) URL pointing to .ipa file to be installed
# This option should be defined along with `display_image_url` and `full_size_image_url` options.
#ios.manifest.app_url =

# (str) URL pointing to an icon (57x57px) to be displayed during download
# This option should be defined along with `app_url` and `full_size_image_url` options.
#ios.manifest.display_image_url =

# (str) URL pointing to a large icon (512x512px) to be used by iTunes
# This option should be defined along with `app_url` and `display_image_url` options.
#ios.manifest.full_size_image_url =


[buildozer]

# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

# (int) Display warning if buildozer is run as root (0 = False, 1 = True)
warn_on_root = 1

# (str) Path to build artifact storage, absolute or relative to spec file
# build_dir = ./.buildozer

# (str) Path to build output (i.e. .apk, .aab, .ipa) storage
# bin_dir = ./bin

#    -----------------------------------------------------------------------------
#    List as sections
#
#    You can define all the "list" as [section:key].
#    Each line will be considered as a option to the list.
#    Let's take [app] / source.exclude_patterns.
#    Instead of doing:
#
#[app]
#source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
#
#    This can be translated into:
#
#[app:source.exclude_patterns]
#license
#data/audio/*.wav
#data/images/original/*
#


#    -----------------------------------------------------------------------------
#    Profiles
#
#    You can extend section / key with a profile
#    For example, you want to deploy a demo version of your application without
#    HD content. You could first change the title to add "(demo)" in the name
#    and extend the excluded directories to remove the HD content.
#
#[app@demo]
#title = My Application (demo)
#
#[app:source.exclude_patterns@demo]
#images/hd/*
#
#    Then, invoke the command line with the "demo" profile:
#
#buildozer --profile demo android debug

buildozer.spec的代码,进入环境:source /mnt/d/kivy/1/buildozer_venv/bin/activate

生成 buildozer.spec(已在项目目录执行过 buildozer init

构建 APK:buildozer -v android debug 我用的是Python 3.11.9,在wsl下运行的。这个环境不好搞,我搞了3天。

python 复制代码
"""
相册浏览 App · 局域网发送版 v5
========================================
v5 更新:
1. 修复 Popup 标题中文乱码(把中文字体注册为默认 Roboto)
2. 分类对话框新增"未分类"入口,方便快速给新图打标签
3. 标签对话框:新标签输入框移到最上面
4. 发送对话框:本机 IP / 对方 IP 信息置顶,更醒目
"""

import os
import io
import json
import socket
import sqlite3
import threading
import hashlib
import shutil
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.image import Image
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
from kivy.uix.progressbar import ProgressBar
from kivy.core.text import LabelBase
from kivy.clock import Clock
from kivy.logger import Logger
from kivy.utils import platform
from kivy.graphics import Color, Rectangle
from PIL import Image as PilImage

# ---------- 权限 ----------
if platform == 'android':
    from android.permissions import request_permissions, Permission
else:
    Permission = None
    def request_permissions(*args, **kwargs):
        pass


# ---------- 中文字体 ----------
def register_chinese_font():
    """注册中文字体,并覆盖默认 Roboto(让 Popup 标题等也显示中文)"""
    font_paths = [
        '/system/fonts/NotoSansCJK-Regular.ttc',
        '/system/fonts/DroidSansFallback.ttf',
        '/system/fonts/SourceHanSansSC-Regular.otf',
        '/system/fonts/Roboto-Regular.ttf',
    ]
    for path in font_paths:
        if os.path.exists(path):
            try:
                # 1) 注册为 Chinese(应用内部显式使用)
                LabelBase.register(name='Chinese', fn_regular=path)
                # 2) 覆盖默认 Roboto,让所有未指定字体的控件(含 Popup 标题)
                #    也使用中文字体,从而解决标题乱码
                LabelBase.register(
                    name='Roboto',
                    fn_regular=path,
                    fn_bold=path,
                    fn_italic=path,
                    fn_bolditalic=path,
                )
                return 'Chinese'
            except Exception:
                continue
    return 'Roboto'

DEFAULT_FONT = register_chinese_font()

# 特殊筛选标记:未分类
TAG_FILTER_UNTAGGED = '__UNTAGGED__'


def show_popup(title, message):
    content = BoxLayout(orientation='vertical', spacing=10, padding=10)
    label = Label(text=message, font_name=DEFAULT_FONT,
                  text_size=(400, None), halign='left', valign='middle')
    label.bind(size=label.setter('text_size'))
    content.add_widget(label)
    btn = Button(text='关闭', font_name=DEFAULT_FONT, size_hint_y=None, height=40)
    popup = Popup(title=title, content=content, size_hint=(0.85, 0.55),
                  auto_dismiss=False)
    btn.bind(on_press=popup.dismiss)
    content.add_widget(btn)
    popup.open()


def tag_display_width(text):
    """中文算 2,其它算 1。上限 10。"""
    w = 0
    for ch in text:
        w += 2 if '\u4e00' <= ch <= '\u9fff' else 1
    return w


# ============================================================
#  SQLite 数据库
# ============================================================
class ImageDB:

    def __init__(self, db_path):
        self.db_path = db_path
        self._lock = threading.Lock()
        self._init_db()

    def _connect(self):
        conn = sqlite3.connect(self.db_path, timeout=10)
        try:
            conn.execute('PRAGMA journal_mode=WAL')
            conn.execute('PRAGMA synchronous=NORMAL')
        except Exception:
            pass
        return conn

    def _init_db(self):
        with self._lock:
            conn = self._connect()
            try:
                conn.executescript('''
                    CREATE TABLE IF NOT EXISTS sent_images (
                        path          TEXT PRIMARY KEY,
                        first_sent_at INTEGER,
                        last_sent_at  INTEGER,
                        send_count    INTEGER DEFAULT 0
                    );
                    CREATE TABLE IF NOT EXISTS send_records (
                        id       INTEGER PRIMARY KEY AUTOINCREMENT,
                        path     TEXT,
                        ip       TEXT,
                        sent_at  INTEGER
                    );
                    CREATE INDEX IF NOT EXISTS idx_records_path
                        ON send_records(path);
                    CREATE TABLE IF NOT EXISTS thumbnails (
                        path   TEXT PRIMARY KEY,
                        mtime  REAL,
                        data   BLOB
                    );
                    CREATE TABLE IF NOT EXISTS tags (
                        id         INTEGER PRIMARY KEY AUTOINCREMENT,
                        name       TEXT UNIQUE NOT NULL,
                        created_at INTEGER
                    );
                    CREATE TABLE IF NOT EXISTS image_tags (
                        path   TEXT,
                        tag_id INTEGER,
                        PRIMARY KEY (path, tag_id)
                    );
                    CREATE INDEX IF NOT EXISTS idx_image_tags_path
                        ON image_tags(path);
                    CREATE INDEX IF NOT EXISTS idx_image_tags_tag
                        ON image_tags(tag_id);
                ''')
                conn.commit()
            finally:
                conn.close()

    # ---------------- 发送记录 ----------------
    def mark_sent(self, path, ip):
        now = int(time.time())
        with self._lock:
            conn = self._connect()
            try:
                conn.execute('''
                    INSERT INTO sent_images(path, first_sent_at, last_sent_at, send_count)
                    VALUES (?, ?, ?, 1)
                    ON CONFLICT(path) DO UPDATE SET
                        last_sent_at = excluded.last_sent_at,
                        send_count   = sent_images.send_count + 1
                ''', (path, now, now))
                conn.execute(
                    'INSERT INTO send_records(path, ip, sent_at) VALUES (?, ?, ?)',
                    (path, ip, now))
                conn.commit()
            finally:
                conn.close()

    def get_sent_paths(self):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('SELECT path FROM sent_images')
                return {row[0] for row in cur.fetchall()}
            finally:
                conn.close()

    def get_sent_ips(self, path):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute(
                    'SELECT DISTINCT ip FROM send_records WHERE path=? ORDER BY sent_at DESC',
                    (path,))
                return [row[0] for row in cur.fetchall()]
            finally:
                conn.close()

    def get_stats(self):
        with self._lock:
            conn = self._connect()
            try:
                sent_cnt = conn.execute('SELECT COUNT(*) FROM sent_images').fetchone()[0]
                rec_cnt = conn.execute('SELECT COUNT(*) FROM send_records').fetchone()[0]
                thumb_cnt = conn.execute('SELECT COUNT(*) FROM thumbnails').fetchone()[0]
                tag_cnt = conn.execute('SELECT COUNT(*) FROM tags').fetchone()[0]
                itag_cnt = conn.execute('SELECT COUNT(*) FROM image_tags').fetchone()[0]
                return sent_cnt, rec_cnt, thumb_cnt, tag_cnt, itag_cnt
            finally:
                conn.close()

    # ---------------- 缩略图 ----------------
    def get_thumb(self, path, mtime):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute(
                    'SELECT data FROM thumbnails WHERE path=? AND mtime>=?',
                    (path, mtime))
                row = cur.fetchone()
                return row[0] if row else None
            finally:
                conn.close()

    def put_thumb(self, path, mtime, data):
        with self._lock:
            conn = self._connect()
            try:
                conn.execute('''
                    INSERT INTO thumbnails(path, mtime, data) VALUES (?, ?, ?)
                    ON CONFLICT(path) DO UPDATE SET
                        mtime = excluded.mtime,
                        data  = excluded.data
                ''', (path, mtime, data))
                conn.commit()
            finally:
                conn.close()

    # ---------------- 标签 ----------------
    def get_all_tags(self):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('''
                    SELECT t.id, t.name, COUNT(it.path) as cnt
                    FROM tags t
                    LEFT JOIN image_tags it ON t.id = it.tag_id
                    GROUP BY t.id, t.name
                    ORDER BY t.name
                ''')
                return cur.fetchall()
            finally:
                conn.close()

    def add_tag(self, name):
        name = name.strip()
        if not name:
            return None
        now = int(time.time())
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('SELECT id FROM tags WHERE name=?', (name,))
                row = cur.fetchone()
                if row:
                    return row[0]
                cur = conn.execute(
                    'INSERT INTO tags(name, created_at) VALUES (?, ?)',
                    (name, now))
                conn.commit()
                return cur.lastrowid
            finally:
                conn.close()

    def delete_tag(self, tag_id):
        with self._lock:
            conn = self._connect()
            try:
                conn.execute('DELETE FROM image_tags WHERE tag_id=?', (tag_id,))
                conn.execute('DELETE FROM tags WHERE id=?', (tag_id,))
                conn.commit()
            finally:
                conn.close()

    def tag_image(self, path, tag_id):
        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    'INSERT OR IGNORE INTO image_tags(path, tag_id) VALUES (?, ?)',
                    (path, tag_id))
                conn.commit()
            finally:
                conn.close()

    def untag_image(self, path, tag_id):
        with self._lock:
            conn = self._connect()
            try:
                conn.execute(
                    'DELETE FROM image_tags WHERE path=? AND tag_id=?',
                    (path, tag_id))
                conn.commit()
            finally:
                conn.close()

    def get_tags_for_image(self, path):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('''
                    SELECT t.id, t.name FROM tags t
                    JOIN image_tags it ON t.id = it.tag_id
                    WHERE it.path=?
                    ORDER BY t.name
                ''', (path,))
                return cur.fetchall()
            finally:
                conn.close()

    def get_tags_map(self, paths):
        if not paths:
            return {}
        result = {p: [] for p in paths}
        with self._lock:
            conn = self._connect()
            try:
                chunk_size = 500
                for i in range(0, len(paths), chunk_size):
                    chunk = paths[i:i + chunk_size]
                    placeholders = ','.join('?' * len(chunk))
                    cur = conn.execute(f'''
                        SELECT it.path, t.name FROM image_tags it
                        JOIN tags t ON t.id = it.tag_id
                        WHERE it.path IN ({placeholders})
                        ORDER BY t.name
                    ''', chunk)
                    for path, name in cur.fetchall():
                        if path in result:
                            result[path].append(name)
                return result
            finally:
                conn.close()

    def get_paths_with_tag(self, tag_name):
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('''
                    SELECT it.path FROM image_tags it
                    JOIN tags t ON t.id = it.tag_id
                    WHERE t.name=?
                ''', (tag_name,))
                return [r[0] for r in cur.fetchall()]
            finally:
                conn.close()

    def get_all_tagged_paths(self):
        """返回所有有标签的图片路径(用于快速算未分类)"""
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('SELECT DISTINCT path FROM image_tags')
                return {row[0] for row in cur.fetchall()}
            finally:
                conn.close()

    # ---------------- 目录清查 ----------------
    def cleanup_missing(self):
        removed_sent = 0
        removed_thumb = 0
        removed_itag = 0
        with self._lock:
            conn = self._connect()
            try:
                cur = conn.execute('SELECT path FROM sent_images')
                dead = [r[0] for r in cur.fetchall() if not os.path.isfile(r[0])]
                if dead:
                    conn.executemany('DELETE FROM sent_images WHERE path=?',
                                     [(p,) for p in dead])
                    conn.executemany('DELETE FROM send_records WHERE path=?',
                                     [(p,) for p in dead])
                    removed_sent = len(dead)

                cur = conn.execute('SELECT path FROM thumbnails')
                dead_thumbs = [r[0] for r in cur.fetchall()
                               if not os.path.isfile(r[0])]
                if dead_thumbs:
                    conn.executemany('DELETE FROM thumbnails WHERE path=?',
                                     [(p,) for p in dead_thumbs])
                    removed_thumb = len(dead_thumbs)

                cur = conn.execute('SELECT DISTINCT path FROM image_tags')
                dead_itags = [r[0] for r in cur.fetchall()
                              if not os.path.isfile(r[0])]
                if dead_itags:
                    conn.executemany('DELETE FROM image_tags WHERE path=?',
                                     [(p,) for p in dead_itags])
                    removed_itag = len(dead_itags)

                conn.commit()
                try:
                    conn.execute('VACUUM')
                except Exception:
                    pass
            finally:
                conn.close()
        return removed_sent, removed_thumb, removed_itag

    # ---------------- 导出 ----------------
    def export_to(self, dst_path):
        with self._lock:
            src = sqlite3.connect(self.db_path, timeout=10)
            dst = sqlite3.connect(dst_path)
            try:
                src.backup(dst)
                dst.commit()
            finally:
                try:
                    dst.close()
                except Exception:
                    pass
                try:
                    src.close()
                except Exception:
                    pass


# ============================================================
#  缩略图按钮
# ============================================================
class ThumbnailButton(Button):
    COLOR_SENT = (1.0, 0.85, 0.20, 0.55)
    COLOR_NEW = (0.20, 0.60, 1.00, 0.50)
    TAG_BAR_HEIGHT = 16

    def __init__(self, thumb_source, original_path, is_sent=False,
                 tags=None, **kwargs):
        super().__init__(**kwargs)
        self.original_path = original_path
        self.is_sent = is_sent
        self.selected = False
        self._last_touch_time = 0
        self._thumb_source = thumb_source
        self.tags = list(tags) if tags else []

        self.background_normal = ''
        self.background_color = (0, 0, 0, 0)

        self.image = Image(source=thumb_source,
                           allow_stretch=True, keep_ratio=True)
        self.add_widget(self.image)

        rgba = self.COLOR_SENT if is_sent else self.COLOR_NEW
        self._highlight_rgba = rgba
        with self.canvas.after:
            self.select_color = Color(rgba[0], rgba[1], rgba[2], 0)
            self.select_rect = Rectangle(size=self.size, pos=self.pos)
        self.bind(pos=self.update_rect, size=self.update_rect)

        self._tag_label = None
        self._tag_bg = None
        if self.tags:
            self._build_tag_label()

    def _build_tag_label(self):
        text = ' · '.join(self.tags)
        if len(text) > 18:
            text = text[:17] + '...'

        self._tag_label = Label(
            text=text,
            font_name=DEFAULT_FONT,
            font_size='10sp',
            color=(1, 1, 0.3, 1),
            size_hint=(None, None),
            halign='center',
            valign='middle',
            shorten=True,
            shorten_from='right',
        )
        with self._tag_label.canvas.before:
            Color(0, 0, 0, 0.55)
            self._tag_bg = Rectangle(pos=self._tag_label.pos,
                                     size=self._tag_label.size)
        self.add_widget(self._tag_label)
        self._tag_label.bind(
            size=lambda w, s: setattr(w, 'text_size', (s[0], None)))
        self.bind(pos=self._layout_tag, size=self._layout_tag)
        self._layout_tag()

    def _layout_tag(self, *args):
        if not self._tag_label:
            return
        w, h = self.width, self.TAG_BAR_HEIGHT
        self._tag_label.size = (w, h)
        self._tag_label.pos = self.pos
        if self._tag_bg is not None:
            self._tag_bg.pos = self.pos
            self._tag_bg.size = (w, h)

    def update_rect(self, *args):
        self.select_rect.size = self.size
        self.select_rect.pos = self.pos

    def on_size(self, *args):
        self.image.size = self.size
        self.image.pos = self.pos
        if self._tag_label:
            self._layout_tag()

    def on_touch_down(self, touch):
        if self.collide_point(*touch.pos):
            now = time.time()
            if now - self._last_touch_time < 0.3:
                app = App.get_running_app()
                try:
                    app.show_original_image(self.original_path)
                except Exception as e:
                    show_popup('显示原图失败', str(e))
                self._last_touch_time = 0
                return True
            else:
                self._last_touch_time = now
                self.toggle_selection()
                return True
        return super().on_touch_down(touch)

    def toggle_selection(self):
        self.selected = not self.selected
        self.select_color.rgba = self._highlight_rgba if self.selected \
            else (self._highlight_rgba[0], self._highlight_rgba[1],
                  self._highlight_rgba[2], 0)
        App.get_running_app().update_selection(self.original_path, self.selected)


class TagToggleButton(Button):
    def __init__(self, tag_id, tag_name, active=False, hint='', **kwargs):
        super().__init__(**kwargs)
        self.tag_id = tag_id
        self.tag_name = tag_name
        self.active = active
        self.hint = hint
        self._refresh_text()
        self.bind(on_press=self._on_click)

    def _refresh_text(self):
        mark = '☑' if self.active else '☐'
        self.text = f'{mark} {self.tag_name}{self.hint}'

    def _on_click(self, *args):
        self.active = not self.active
        self._refresh_text()


# ============================================================
#  主应用
# ============================================================
class PhotoBrowserApp(App):

    PORT = 8888
    CHUNK = 64 * 1024
    HEADER_SIZE = 1024
    MAX_RECENT = 8

    # =================================================================
    def build(self):
        if platform == 'android':
            request_permissions([Permission.READ_EXTERNAL_STORAGE,
                                 Permission.WRITE_EXTERNAL_STORAGE])

        self.db_path = os.path.join(self.user_data_dir, 'lanshare.db')
        self.db = ImageDB(self.db_path)
        self._sent_paths_cache = self.db.get_sent_paths()

        self.thumb_tmp_dir = os.path.join(self.user_data_dir, '.thumb_tmp')
        try:
            if os.path.exists(self.thumb_tmp_dir):
                shutil.rmtree(self.thumb_tmp_dir, ignore_errors=True)
            os.makedirs(self.thumb_tmp_dir, exist_ok=True)
        except Exception:
            pass
        try:
            old = os.path.join(self.user_data_dir, '.thumbnails')
            if os.path.exists(old):
                shutil.rmtree(old, ignore_errors=True)
        except Exception:
            pass

        # ---------- 界面 ----------
        self.layout = BoxLayout(orientation='vertical', spacing=5, padding=10)

        top = BoxLayout(size_hint_y=0.08, spacing=6)
        self.dir_label = Label(text='目录: 未选择', font_name=DEFAULT_FONT,
                               size_hint_x=0.45)
        btn_dir = Button(text='选择目录', font_name=DEFAULT_FONT,
                         size_hint_x=0.18, on_press=self.show_dir_chooser)
        btn_cat = Button(text='分类', font_name=DEFAULT_FONT,
                         size_hint_x=0.18, on_press=self.show_tag_filter_dialog)
        btn_more = Button(text='更多', font_name=DEFAULT_FONT,
                          size_hint_x=0.19, on_press=self.show_more_dialog)
        top.add_widget(self.dir_label)
        top.add_widget(btn_dir)
        top.add_widget(btn_cat)
        top.add_widget(btn_more)
        self.layout.add_widget(top)

        ip_bar = BoxLayout(size_hint_y=0.05, spacing=5)
        self.local_ip_label = Label(text='本机 IP: 获取中...',
                                    font_name=DEFAULT_FONT,
                                    color=(0.1, 0.5, 0.1, 1), size_hint_x=0.7)
        btn_switch_ip = Button(text='切换IP', font_name=DEFAULT_FONT,
                               size_hint_x=0.15, on_press=self.cycle_local_ip)
        btn_recheck_ip = Button(text='重查', font_name=DEFAULT_FONT,
                                size_hint_x=0.15,
                                on_press=self.refresh_local_ip)
        ip_bar.add_widget(self.local_ip_label)
        ip_bar.add_widget(btn_switch_ip)
        ip_bar.add_widget(btn_recheck_ip)
        self.layout.add_widget(ip_bar)

        self.grid = GridLayout(cols=3, spacing=5, size_hint_y=0.78)
        self.layout.add_widget(self.grid)

        bottom = BoxLayout(size_hint_y=0.09, spacing=6)
        self.prev_btn = Button(text='上一页', font_name=DEFAULT_FONT,
                               on_press=self.prev_page)
        self.tag_btn_bottom = Button(text='标签', font_name=DEFAULT_FONT,
                                     on_press=self.show_tag_dialog)
        self.send_btn_bottom = Button(text='发送 (0)', font_name=DEFAULT_FONT,
                                      on_press=self.show_send_dialog)
        self.next_btn = Button(text='下一页', font_name=DEFAULT_FONT,
                               on_press=self.next_page)
        bottom.add_widget(self.prev_btn)
        bottom.add_widget(self.tag_btn_bottom)
        bottom.add_widget(self.send_btn_bottom)
        bottom.add_widget(self.next_btn)
        self.layout.add_widget(bottom)

        # ---------- 状态 ----------
        self.image_paths = []
        self._all_image_paths = []
        self.tag_filter = None

        self.current_page = 0
        self.page_size = 9
        self.selected_paths = set()
        self.current_dir = None
        self._send_popup = None
        self._dir_popup = None
        self._more_popup = None

        self._all_local_ips = []
        self._local_ip_index = 0
        self._local_ip = '获取中...'

        self._settings_path = os.path.join(self.user_data_dir, 'settings.json')
        self._settings = self._load_settings()
        try:
            self.PORT = int(self._settings.get('port', 8888)) or 8888
        except Exception:
            self.PORT = 8888

        self.refresh_local_ip(None)

        default_dirs = [
            '/storage/emulated/0/DCIM/Camera',
            '/storage/emulated/0/Pictures',
            '/storage/emulated/0/Download',
            '/sdcard/DCIM/Camera',
            '/sdcard/Pictures',
            '/sdcard/Download',
        ]
        for d in default_dirs:
            if os.path.exists(d):
                self.current_dir = d
                self.dir_label.text = f'目录: {d}'
                break
        if self.current_dir:
            threading.Thread(target=self.scan_images,
                             args=(self.current_dir,), daemon=True).start()
        else:
            self.dir_label.text = '目录: 未找到默认目录'

        return self.layout

    # =================================================================
    #  设置读写
    # =================================================================
    def _load_settings(self):
        try:
            if os.path.exists(self._settings_path):
                with open(self._settings_path, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    if isinstance(data, dict):
                        return data
        except Exception as e:
            Logger.warning(f'读取设置失败: {e}')
        return {}

    def _save_settings(self):
        try:
            os.makedirs(os.path.dirname(self._settings_path), exist_ok=True)
            with open(self._settings_path, 'w', encoding='utf-8') as f:
                json.dump(self._settings, f, ensure_ascii=False, indent=2)
        except Exception as e:
            Logger.warning(f'保存设置失败: {e}')

    def _add_recent_target(self, ip):
        lst = self._settings.get('recent_targets', [])
        if not isinstance(lst, list):
            lst = []
        if ip in lst:
            lst.remove(ip)
        lst.insert(0, ip)
        lst = lst[:self.MAX_RECENT]
        self._settings['recent_targets'] = lst
        try:
            self._settings['last_ip_suffix'] = str(ip.split('.')[-1])
        except Exception:
            pass
        self._save_settings()

    # =================================================================
    #  本机 IP
    # =================================================================
    def refresh_local_ip(self, instance):
        def worker():
            ips = self._get_all_local_ips()
            Clock.schedule_once(lambda dt: self._apply_local_ips(ips), 0)
        threading.Thread(target=worker, daemon=True).start()

    def cycle_local_ip(self, instance):
        if len(self._all_local_ips) < 2:
            return
        self._local_ip_index = (self._local_ip_index + 1) % len(self._all_local_ips)
        self._local_ip = self._all_local_ips[self._local_ip_index]
        self._update_local_ip_label()

    def _apply_local_ips(self, ips):
        self._all_local_ips = ips or []
        if ips:
            best = self._pick_best_ip(ips)
            self._local_ip_index = ips.index(best) if best in ips else 0
            self._local_ip = ips[self._local_ip_index]
        else:
            self._local_ip = '无法获取'
        self._update_local_ip_label()

    def _update_local_ip_label(self):
        n = len(self._all_local_ips)
        if n == 0:
            self.local_ip_label.text = '本机 IP: 无法获取'
            return
        suffix = f'  ({self._local_ip_index + 1}/{n},点"切换IP")' if n > 1 else ''
        tip = '  [热点]' if self._is_hotspot_ip(self._local_ip) else ''
        self.local_ip_label.text = f'本机 IP: {self._local_ip}{tip}{suffix}'

    @staticmethod
    def _is_hotspot_ip(ip):
        return (ip.startswith('192.168.43.') or
                ip.startswith('192.168.42.') or
                ip.startswith('192.168.44.') or
                ip.startswith('172.20.10.'))

    @staticmethod
    def _pick_best_ip(ips):
        if not ips:
            return None
        for ip in ips:
            if (ip.startswith('192.168.43.') or
                ip.startswith('192.168.42.') or
                ip.startswith('172.20.10.')):
                return ip
        for ip in ips:
            if ip.startswith('192.168.'):
                return ip
        for ip in ips:
            if not ip.startswith('169.254.') and not ip.startswith('127.'):
                return ip
        return ips[0]

    def _get_all_local_ips(self):
        ips = []

        if platform == 'android':
            try:
                from jnius import autoclass
                NetworkInterface = autoclass('java.net.NetworkInterface')
                interfaces = NetworkInterface.getNetworkInterfaces()
                while interfaces.hasMoreElements():
                    iface = interfaces.nextElement()
                    try:
                        if iface.isLoopback() or not iface.isUp():
                            continue
                    except Exception:
                        pass
                    addrs = iface.getInetAddresses()
                    while addrs.hasMoreElements():
                        addr = addrs.nextElement()
                        try:
                            if addr.isLoopbackAddress():
                                continue
                            ip = addr.getHostAddress()
                            if not ip or ':' in ip:
                                continue
                            if ip.startswith('169.254.'):
                                continue
                            if ip not in ips:
                                ips.append(ip)
                        except Exception:
                            continue
            except Exception as e:
                Logger.warning(f'NetworkInterface 遍历失败: {e}')

        if not ips:
            for target in ('8.8.8.8', '192.168.43.1',
                           '192.168.1.1', '192.168.0.1'):
                try:
                    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                    s.settimeout(1)
                    s.connect((target, 80))
                    ip = s.getsockname()[0]
                    s.close()
                    if ip and not ip.startswith('127.') and ip not in ips:
                        ips.append(ip)
                        break
                except Exception:
                    continue

        if not ips:
            try:
                ip = socket.gethostbyname(socket.gethostname())
                if ip and not ip.startswith('127.'):
                    ips.append(ip)
            except Exception:
                pass

        ips.sort(key=lambda x: (
            0 if x.endswith('.1') else 1,
            0 if x.startswith('192.168.43.') else
            1 if x.startswith('192.168.') else 2,
        ))
        return ips

    # =================================================================
    #  扫描
    # =================================================================
    def scan_images(self, dir_path):
        try:
            if not os.path.exists(dir_path):
                raise FileNotFoundError(f'目录不存在: {dir_path}')
            if not os.path.isdir(dir_path):
                raise NotADirectoryError(f'路径不是目录: {dir_path}')

            image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp')
            paths = []
            try:
                for f in os.listdir(dir_path):
                    filepath = os.path.join(dir_path, f)
                    if os.path.isfile(filepath) and \
                            filepath.lower().endswith(image_extensions):
                        paths.append(filepath)
            except PermissionError as e:
                raise PermissionError(f'权限不足,无法读取目录: {dir_path}\n{e}')
            except Exception as e:
                raise Exception(f'扫描目录失败: {e}')

            if not paths:
                try:
                    has_any = any(os.listdir(dir_path))
                except Exception:
                    has_any = False
                label = f'{dir_path} (空目录)' if not has_any \
                    else f'{dir_path} (无图片)'
                Clock.schedule_once(
                    lambda dt: setattr(self.dir_label, 'text', label), 0)
                Clock.schedule_once(lambda dt: self.grid.clear_widgets(), 0)
                return

            paths.sort(key=lambda p: os.path.getmtime(p), reverse=True)
            self._all_image_paths = paths
            self._prefetch_thumbnails(paths)

            self._apply_tag_filter()
            Clock.schedule_once(
                lambda dt: setattr(self.dir_label, 'text',
                                   self._make_dir_label()), 0)

        except Exception as e:
            error_msg = str(e)
            Logger.error(f'扫描失败: {error_msg}')
            Clock.schedule_once(lambda dt: show_popup('扫描错误', error_msg), 0)
            Clock.schedule_once(
                lambda dt: setattr(self.dir_label, 'text', '扫描出错'), 0)

    def _make_dir_label(self):
        n = len(self.image_paths)
        base = self.current_dir or ''
        if self.tag_filter is None:
            return f'{base} ({n}张)'
        if self.tag_filter == TAG_FILTER_UNTAGGED:
            return f'{base} [未分类] ({n}张)'
        return f'{base} [{self.tag_filter}] ({n}张)'

    def _prefetch_thumbnails(self, paths):
        for img_path in paths:
            try:
                mtime = os.path.getmtime(img_path)
            except Exception:
                continue
            if self.db.get_thumb(img_path, mtime) is not None:
                continue
            try:
                with PilImage.open(img_path) as img:
                    img.thumbnail((200, 200), PilImage.Resampling.LANCZOS)
                    buf = io.BytesIO()
                    img.save(buf, 'JPEG', quality=80)
                    self.db.put_thumb(img_path, mtime, buf.getvalue())
            except Exception as e:
                Logger.warning(f'缩略图生成失败 {img_path}: {e}')

    def _get_thumb_source(self, path):
        try:
            mtime = os.path.getmtime(path)
        except Exception:
            return path

        data = self.db.get_thumb(path, mtime)
        if data is None:
            try:
                with PilImage.open(path) as img:
                    img.thumbnail((200, 200), PilImage.Resampling.LANCZOS)
                    buf = io.BytesIO()
                    img.save(buf, 'JPEG', quality=80)
                    data = buf.getvalue()
                self.db.put_thumb(path, mtime, data)
            except Exception as e:
                Logger.warning(f'读取/生成缩略图失败 {path}: {e}')
                return path

        try:
            hash_name = hashlib.md5(path.encode('utf-8')).hexdigest() + '.jpg'
            tmp = os.path.join(self.thumb_tmp_dir, hash_name)
            with open(tmp, 'wb') as f:
                f.write(data)
            return tmp
        except Exception:
            return path

    # =================================================================
    #  标签筛选(含"未分类")
    # =================================================================
    def _apply_tag_filter(self):
        if not self._all_image_paths:
            self.image_paths = []
            Clock.schedule_once(lambda dt: self.grid.clear_widgets(), 0)
            return

        if self.tag_filter is None:
            self.image_paths = list(self._all_image_paths)
        elif self.tag_filter == TAG_FILTER_UNTAGGED:
            try:
                tagged_paths = self.db.get_all_tagged_paths()
            except Exception as e:
                Logger.warning(f'获取已标签失败: {e}')
                tagged_paths = set()
            self.image_paths = [p for p in self._all_image_paths
                                if p not in tagged_paths]
        else:
            try:
                tagged_set = set(self.db.get_paths_with_tag(self.tag_filter))
            except Exception as e:
                Logger.warning(f'筛选失败: {e}')
                tagged_set = set()
            self.image_paths = [p for p in self._all_image_paths
                                if p in tagged_set]

        self.current_page = 0
        if self.image_paths:
            Clock.schedule_once(lambda dt: self.load_page(0), 0)
        else:
            Clock.schedule_once(lambda dt: self.grid.clear_widgets(), 0)
            Clock.schedule_once(lambda dt: self.update_select_button(), 0)

    def show_tag_filter_dialog(self, instance):
        popup = Popup(title='分类筛选', size_hint=(0.9, 0.8))
        content = BoxLayout(orientation='vertical', spacing=6, padding=10)

        cur_text = '当前:全部'
        if self.tag_filter == TAG_FILTER_UNTAGGED:
            cur_text = '当前:未分类'
        elif self.tag_filter:
            cur_text = f'当前:{self.tag_filter}'
        content.add_widget(Label(
            text=cur_text + '   (点 ✕ 可删除标签)',
            font_name=DEFAULT_FONT, size_hint_y=None, height=26,
            color=(0.3, 0.3, 0.3, 1)))

        scroll = ScrollView()
        grid = GridLayout(cols=1, spacing=4, size_hint_y=None)
        grid.bind(minimum_height=grid.setter('height'))

        def choose(tag_name):
            self.tag_filter = tag_name
            self.selected_paths.clear()
            self.update_select_button()
            self._apply_tag_filter()
            Clock.schedule_once(
                lambda dt: setattr(self.dir_label, 'text',
                                   self._make_dir_label()), 0)
            popup.dismiss()

        def rebuild():
            grid.clear_widgets()
            try:
                cur_tags = self.db.get_all_tags()
            except Exception:
                cur_tags = []

            total = len(self._all_image_paths)

            # 1) 全部
            mark = '▶ ' if self.tag_filter is None else ''
            btn_all = Button(
                text=f'{mark}全部 ({total})',
                font_name=DEFAULT_FONT, size_hint_y=None, height=44)
            btn_all.bind(on_press=lambda x: choose(None))
            grid.add_widget(btn_all)

            # 2) 未分类
            try:
                tagged_paths = self.db.get_all_tagged_paths()
            except Exception:
                tagged_paths = set()
            untagged_count = sum(1 for p in self._all_image_paths
                                 if p not in tagged_paths)
            mark = '▶ ' if self.tag_filter == TAG_FILTER_UNTAGGED else ''
            btn_untagged = Button(
                text=f'{mark}未分类 ({untagged_count})',
                font_name=DEFAULT_FONT, size_hint_y=None, height=44,
                background_color=(1.0, 0.88, 0.4, 1))
            btn_untagged.bind(on_press=lambda x: choose(TAG_FILTER_UNTAGGED))
            grid.add_widget(btn_untagged)

            # 3) 各标签
            for tid, name, cnt in cur_tags:
                row = BoxLayout(orientation='horizontal',
                                size_hint_y=None, height=44, spacing=4)
                mark = '▶ ' if name == self.tag_filter else ''
                btn = Button(text=f'{mark}{name} ({cnt})',
                             font_name=DEFAULT_FONT, size_hint_x=0.82)
                btn.bind(on_press=lambda x, n=name: choose(n))
                row.add_widget(btn)

                del_btn = Button(text='✕', font_name=DEFAULT_FONT,
                                 size_hint_x=0.18,
                                 background_color=(0.9, 0.3, 0.3, 1))
                del_btn.bind(on_press=lambda x, _id=tid, _n=name:
                             confirm_delete(_id, _n))
                row.add_widget(del_btn)
                grid.add_widget(row)

            if not cur_tags:
                grid.add_widget(Label(
                    text='(还没有标签,先在图片上打标签)',
                    font_name=DEFAULT_FONT, size_hint_y=None, height=30,
                    color=(0.6, 0.6, 0.6, 1)))

        def confirm_delete(tag_id, name):
            confirm = Popup(title='确认删除', size_hint=(0.78, 0.45))
            c_box = BoxLayout(orientation='vertical', spacing=10, padding=14)
            c_box.add_widget(Label(
                text=f'确定删除标签「{name}」吗?\n'
                     f'所有图片上的这个标签都会被移除。',
                font_name=DEFAULT_FONT))

            c_row = BoxLayout(size_hint_y=None, height=44, spacing=8)

            def do_del(x):
                try:
                    self.db.delete_tag(tag_id)
                except Exception as e:
                    show_popup('删除失败', str(e))
                    return
                confirm.dismiss()
                if self.tag_filter == name:
                    self.tag_filter = None
                    self._apply_tag_filter()
                    Clock.schedule_once(
                        lambda dt: setattr(self.dir_label, 'text',
                                           self._make_dir_label()), 0)
                rebuild()
                if self.image_paths:
                    self.load_page(self.current_page)

            def do_cancel(x):
                confirm.dismiss()

            c_row.add_widget(Button(text='取消', font_name=DEFAULT_FONT,
                                    on_press=do_cancel))
            c_row.add_widget(Button(text='删除', font_name=DEFAULT_FONT,
                                    on_press=do_del,
                                    background_color=(0.9, 0.3, 0.3, 1)))
            c_box.add_widget(c_row)
            confirm.content = c_box
            confirm.open()

        rebuild()
        scroll.add_widget(grid)
        content.add_widget(scroll)

        btn_close = Button(text='关闭', font_name=DEFAULT_FONT,
                           size_hint_y=None, height=44)
        btn_close.bind(on_press=popup.dismiss)
        content.add_widget(btn_close)

        popup.content = content
        popup.open()

    # =================================================================
    #  打标签(新标签输入框置顶)
    # =================================================================
    def show_tag_dialog(self, instance):
        if not self.selected_paths:
            show_popup('提示', '请先选中要打标签的图片')
            return

        paths = list(self.selected_paths)
        try:
            tag_map = self.db.get_tags_map(paths)
        except Exception as e:
            show_popup('错误', f'读取标签失败: {e}')
            return

        cnt_per_tag = Counter()
        for p in paths:
            for n in tag_map.get(p, []):
                cnt_per_tag[n] += 1

        popup = Popup(title=f'为 {len(paths)} 张图片设置标签',
                      size_hint=(0.92, 0.88))
        layout = BoxLayout(orientation='vertical', spacing=6, padding=10)

        # ---------- 1) 新建标签放最上面 ----------
        layout.add_widget(Label(
            text='新建标签(中文≤5字或≤10字符)',
            font_name=DEFAULT_FONT, size_hint_y=None, height=22,
            color=(0.3, 0.3, 0.3, 1)))

        new_row = BoxLayout(size_hint_y=None, height=46, spacing=4)
        new_input = TextInput(multiline=False, font_name=DEFAULT_FONT,
                              hint_text='输入新标签名',
                              size_hint_x=0.7)
        new_row.add_widget(new_input)
        layout.add_widget(new_row)

        # ---------- 2) 提示 ----------
        layout.add_widget(Label(
            text='勾选要应用的标签(取消勾选会移除)· 点 ✕ 删除标签',
            font_name=DEFAULT_FONT, size_hint_y=None, height=22,
            color=(0.45, 0.45, 0.45, 1)))

        # ---------- 3) 标签列表 ----------
        scroll = ScrollView()
        tag_grid = GridLayout(cols=1, spacing=4, size_hint_y=None)
        tag_grid.bind(minimum_height=tag_grid.setter('height'))
        scroll.add_widget(tag_grid)
        layout.add_widget(scroll)

        tag_buttons = {}

        def rebuild_tag_buttons():
            tag_grid.clear_widgets()
            tag_buttons.clear()

            try:
                current = self.db.get_all_tags()
            except Exception:
                current = []

            if not current:
                tag_grid.add_widget(Label(
                    text='(还没有标签,可在上方新建)',
                    font_name=DEFAULT_FONT, size_hint_y=None, height=30,
                    color=(0.6, 0.6, 0.6, 1)))
                return

            for tid, name, cnt in current:
                in_sel = cnt_per_tag.get(name, 0)
                if in_sel == 0:
                    active = False
                    hint = ''
                elif in_sel == len(paths):
                    active = True
                    hint = ''
                else:
                    active = True
                    hint = f' ({in_sel}/{len(paths)})'

                row = BoxLayout(orientation='horizontal',
                                size_hint_y=None, height=44, spacing=4)

                btn = TagToggleButton(
                    tag_id=tid, tag_name=name,
                    active=active, hint=hint,
                    font_name=DEFAULT_FONT,
                    size_hint_x=0.82)
                tag_buttons[name] = btn
                row.add_widget(btn)

                del_btn = Button(text='✕', font_name=DEFAULT_FONT,
                                 size_hint_x=0.18,
                                 background_color=(0.9, 0.3, 0.3, 1))
                del_btn.bind(on_press=lambda x, _id=tid, _n=name:
                             confirm_delete_tag(_id, _n))
                row.add_widget(del_btn)

                tag_grid.add_widget(row)

        def confirm_delete_tag(tag_id, name):
            confirm = Popup(title='确认删除', size_hint=(0.78, 0.45))
            c_box = BoxLayout(orientation='vertical', spacing=10, padding=14)
            c_box.add_widget(Label(
                text=f'确定删除标签「{name}」吗?\n'
                     f'所有图片上的这个标签都会被移除。',
                font_name=DEFAULT_FONT))

            c_row = BoxLayout(size_hint_y=None, height=44, spacing=8)

            def do_del(x):
                try:
                    self.db.delete_tag(tag_id)
                except Exception as e:
                    show_popup('删除失败', str(e))
                    return
                confirm.dismiss()
                if self.tag_filter == name:
                    self.tag_filter = None
                    self._apply_tag_filter()
                    Clock.schedule_once(
                        lambda dt: setattr(self.dir_label, 'text',
                                           self._make_dir_label()), 0)
                cnt_per_tag.pop(name, None)
                rebuild_tag_buttons()
                if self.image_paths:
                    self.load_page(self.current_page)

            def do_cancel(x):
                confirm.dismiss()

            c_row.add_widget(Button(text='取消', font_name=DEFAULT_FONT,
                                    on_press=do_cancel))
            c_row.add_widget(Button(text='删除', font_name=DEFAULT_FONT,
                                    on_press=do_del,
                                    background_color=(0.9, 0.3, 0.3, 1)))
            c_box.add_widget(c_row)
            confirm.content = c_box
            confirm.open()

        # 添加新标签按钮(放在新输入行里)
        def add_tag_action(x):
            name = (new_input.text or '').strip()
            if not name:
                return
            if tag_display_width(name) > 10:
                show_popup('提示', '标签名太长(中文≤5字或≤10字符)')
                return
            try:
                existing = self.db.get_all_tags()
            except Exception:
                existing = []
            if any(t[1] == name for t in existing):
                show_popup('提示', f'标签"{name}"已存在')
                return
            tid = self.db.add_tag(name)
            if tid is None:
                show_popup('提示', '新建失败')
                return
            new_input.text = ''
            cnt_per_tag[name] = 0
            rebuild_tag_buttons()
            # 新加自动勾选,方便一键打上
            if name in tag_buttons:
                tag_buttons[name].active = True
                tag_buttons[name]._refresh_text()

        btn_add = Button(text='添加', font_name=DEFAULT_FONT,
                         size_hint_x=0.3, on_press=add_tag_action)
        new_row.add_widget(btn_add)

        rebuild_tag_buttons()

        # ---------- 4) 底部按钮 ----------
        btn_row = BoxLayout(size_hint_y=None, height=48, spacing=8)

        def do_cancel(x):
            popup.dismiss()

        def do_apply(x):
            applied = 0
            removed = 0
            try:
                name_to_id = {t[1]: t[0] for t in self.db.get_all_tags()}
            except Exception:
                name_to_id = {}

            for name, btn in tag_buttons.items():
                tid = name_to_id.get(name)
                if tid is None:
                    continue
                if btn.active:
                    for p in paths:
                        if name not in tag_map.get(p, []):
                            try:
                                self.db.tag_image(p, tid)
                                applied += 1
                            except Exception as e:
                                Logger.warning(f'打标签失败: {e}')
                else:
                    for p in paths:
                        if name in tag_map.get(p, []):
                            try:
                                self.db.untag_image(p, tid)
                                removed += 1
                            except Exception as e:
                                Logger.warning(f'移除标签失败: {e}')

            popup.dismiss()
            if self.image_paths:
                self.load_page(self.current_page)
            if applied or removed:
                show_popup('完成', f'添加 {applied} 处,移除 {removed} 处')

        btn_cancel = Button(text='取消', font_name=DEFAULT_FONT,
                            on_press=do_cancel)
        btn_ok = Button(text='确认', font_name=DEFAULT_FONT, on_press=do_apply)
        btn_row.add_widget(btn_cancel)
        btn_row.add_widget(btn_ok)
        layout.add_widget(btn_row)

        popup.content = layout
        popup.open()

    # =================================================================
    #  分页
    # =================================================================
    def load_page(self, page):
        if not self.image_paths:
            self.grid.clear_widgets()
            self.update_select_button()
            return
        total_pages = (len(self.image_paths) + self.page_size - 1) // self.page_size
        if page < 0 or page >= total_pages:
            return
        self.current_page = page
        start = page * self.page_size
        end = min(start + self.page_size, len(self.image_paths))
        page_paths = self.image_paths[start:end]

        try:
            tag_map = self.db.get_tags_map(page_paths)
        except Exception as e:
            Logger.warning(f'读取标签失败: {e}')
            tag_map = {}

        self.grid.clear_widgets()
        for orig_path in page_paths:
            source = self._get_thumb_source(orig_path)
            is_sent = orig_path in self._sent_paths_cache
            tags = tag_map.get(orig_path, [])
            btn = ThumbnailButton(source, orig_path,
                                  is_sent=is_sent, tags=tags)
            if orig_path in self.selected_paths:
                btn.selected = True
                btn.select_color.rgba = btn._highlight_rgba
            self.grid.add_widget(btn)

        self.prev_btn.disabled = (page == 0)
        self.next_btn.disabled = (page == total_pages - 1)
        self.update_select_button()

    def prev_page(self, instance):
        if self.current_page > 0:
            self.load_page(self.current_page - 1)

    def next_page(self, instance):
        total_pages = (len(self.image_paths) + self.page_size - 1) // self.page_size
        if self.current_page < total_pages - 1:
            self.load_page(self.current_page + 1)

    def update_selection(self, path, selected):
        if selected:
            self.selected_paths.add(path)
        else:
            self.selected_paths.discard(path)
        self.update_select_button()

    def update_select_button(self):
        self.send_btn_bottom.text = f'发送 ({len(self.selected_paths)})'

    def show_original_image(self, image_path):
        try:
            content = BoxLayout(orientation='vertical')
            img = Image(source=image_path, allow_stretch=True, keep_ratio=True)
            content.add_widget(img)

            parts = []
            if image_path in self._sent_paths_cache:
                ips = self.db.get_sent_ips(image_path)
                parts.append('已发送过')
                if ips:
                    parts.append('目标: ' + ', '.join(ips[:3]))
            else:
                parts.append('尚未发送')

            try:
                tags = [t[1] for t in self.db.get_tags_for_image(image_path)]
                if tags:
                    parts.append('标签: ' + ' / '.join(tags))
            except Exception:
                pass

            info = Label(text='    '.join(parts), font_name=DEFAULT_FONT,
                         size_hint_y=None, height=30,
                         color=(0.3, 0.3, 0.3, 1))
            content.add_widget(info)

            popup = Popup(title='原图', content=content,
                          size_hint=(0.95, 0.95), auto_dismiss=True)
            popup.open()
        except Exception as e:
            show_popup('显示原图失败', str(e))

    def refresh_current_dir(self, instance):
        if self.current_dir and os.path.exists(self.current_dir):
            threading.Thread(target=self.scan_images,
                             args=(self.current_dir,), daemon=True).start()
        else:
            show_popup('提示', '当前目录无效,请重新选择')

    # =================================================================
    #  更多
    # =================================================================
    def show_more_dialog(self, instance):
        self._more_popup = Popup(title='更多', size_hint=(0.85, 0.62))
        layout = BoxLayout(orientation='vertical', spacing=10, padding=15)

        try:
            sent_cnt, rec_cnt, thumb_cnt, tag_cnt, itag_cnt = self.db.get_stats()
        except Exception:
            sent_cnt = rec_cnt = thumb_cnt = tag_cnt = itag_cnt = 0

        info = Label(
            text=(f'数据库统计\n'
                  f'· 已发送图片:{sent_cnt} 条\n'
                  f'· 发送历史(含IP):{rec_cnt} 条\n'
                  f'· 缩略图缓存:{thumb_cnt} 张\n'
                  f'· 标签数量:{tag_cnt} 个\n'
                  f'· 标签关联:{itag_cnt} 条'),
            font_name=DEFAULT_FONT, size_hint_y=None, height=150,
            halign='left', valign='middle')
        info.bind(size=info.setter('text_size'))
        layout.add_widget(info)

        btn_clean = Button(text='目录清查', font_name=DEFAULT_FONT,
                           size_hint_y=None, height=52,
                           on_press=lambda x: self.do_cleanup())
        btn_export = Button(text='导出数据库', font_name=DEFAULT_FONT,
                            size_hint_y=None, height=52,
                            on_press=lambda x: self.do_export_db())
        btn_close = Button(text='关闭', font_name=DEFAULT_FONT,
                           size_hint_y=None, height=44,
                           on_press=lambda x: self._more_popup.dismiss())

        layout.add_widget(btn_clean)
        layout.add_widget(btn_export)
        layout.add_widget(btn_close)

        self._more_popup.content = layout
        self._more_popup.open()

    def do_cleanup(self):
        if self._more_popup:
            self._more_popup.dismiss()

        show_popup('提示', '正在清查目录,请稍候...')

        def worker():
            try:
                removed_sent, removed_thumb, removed_itag = self.db.cleanup_missing()
                try:
                    if os.path.exists(self.thumb_tmp_dir):
                        shutil.rmtree(self.thumb_tmp_dir, ignore_errors=True)
                        os.makedirs(self.thumb_tmp_dir, exist_ok=True)
                except Exception:
                    pass

                self._sent_paths_cache = self.db.get_sent_paths()

                def done(dt):
                    try:
                        for w in App.get_running_app().root_window.children:
                            if isinstance(w, Popup):
                                w.dismiss()
                    except Exception:
                        pass
                    show_popup('目录清查完成',
                               f'删除发送记录:{removed_sent} 条\n'
                               f'删除缩略图缓存:{removed_thumb} 张\n'
                               f'删除标签关联:{removed_itag} 条')
                    if self.current_dir:
                        threading.Thread(target=self.scan_images,
                                         args=(self.current_dir,),
                                         daemon=True).start()
                Clock.schedule_once(done, 0)
            except Exception as e:
                Logger.error(f'目录清查失败: {e}')
                Clock.schedule_once(
                    lambda dt: show_popup('清查失败', str(e)), 0)

        threading.Thread(target=worker, daemon=True).start()

    def do_export_db(self):
        if self._more_popup:
            self._more_popup.dismiss()

        try:
            export_path = os.path.join(self.user_data_dir,
                                       'lanshare_export.db')
            self.db.export_to(export_path)
            if not os.path.isfile(export_path):
                show_popup('导出失败', '数据库导出失败')
                return
            size = os.path.getsize(export_path)
            if size == 0:
                show_popup('导出失败', '导出文件大小为 0')
                return

            self._open_send_dialog(
                [export_path],
                title='导出数据库',
                on_finished=lambda: self._cleanup_export(export_path),
            )
        except Exception as e:
            Logger.error(f'导出数据库失败: {e}')
            show_popup('导出失败', str(e))

    @staticmethod
    def _cleanup_export(path):
        try:
            if os.path.exists(path):
                os.remove(path)
        except Exception:
            pass

    # =================================================================
    #  发送(信息置顶)
    # =================================================================
    def show_send_dialog(self, instance):
        if not self.selected_paths:
            show_popup('提示', '未选择任何图片')
            return

        valid_paths = [p for p in self.selected_paths
                       if os.path.isfile(p) and os.path.getsize(p) > 0]
        if not valid_paths:
            show_popup('提示', '选中的图片都已失效')
            self.selected_paths.clear()
            self.update_select_button()
            return

        self._open_send_dialog(
            list(valid_paths),
            title='局域网发送',
            on_finished=self._after_send_images,
        )

    def _after_send_images(self):
        self.selected_paths.clear()
        self.update_select_button()
        if self.image_paths:
            self.load_page(self.current_page)

    def _open_send_dialog(self, paths, title='局域网发送', on_finished=None):
        from kivy.uix.widget import Widget

        if self._local_ip and self._local_ip.count('.') == 3:
            prefix = '.'.join(self._local_ip.split('.')[:3])
        else:
            prefix = '192.168.43'

        self._send_popup = Popup(title=title, size_hint=(0.95, 0.78))
        layout = BoxLayout(orientation='vertical', spacing=8, padding=12)

        # ========== 1. 本机信息(顶部醒目) ==========
        tip = ' [热点]' if self._is_hotspot_ip(self._local_ip) else ''
        my_ip_box = BoxLayout(orientation='vertical',
                              size_hint_y=None, height=58, spacing=2)
        my_ip_box.add_widget(Label(
            text=f'📱 本机 IP: {self._local_ip}{tip}',
            font_name=DEFAULT_FONT, size_hint_y=None, height=30,
            color=(0.1, 0.65, 0.15, 1), font_size='17sp'))
        my_ip_box.add_widget(Label(
            text=f'对方用这个 IP 发过来,端口 {self.PORT}',
            font_name=DEFAULT_FONT, size_hint_y=None, height=22,
            color=(0.55, 0.55, 0.55, 1), font_size='12sp'))
        layout.add_widget(my_ip_box)

        # ========== 2. 对方 IP 输入(也放顶部) ==========
        ip_row = BoxLayout(size_hint_y=None, height=46, spacing=6)
        ip_row.add_widget(Label(text='🎯 对方 IP:',
                                font_name=DEFAULT_FONT, size_hint_x=0.22,
                                font_size='15sp'))
        self._send_prefix_label = Label(text=prefix + '.',
                                        font_name=DEFAULT_FONT,
                                        size_hint_x=0.38,
                                        color=(0.15, 0.35, 0.85, 1),
                                        font_size='16sp')
        ip_row.add_widget(self._send_prefix_label)

        last_suffix = str(self._settings.get('last_ip_suffix', '') or '')
        self._send_suffix_input = TextInput(
            text=last_suffix, multiline=False, input_filter='int',
            font_name=DEFAULT_FONT, size_hint_x=0.22,
            font_size='16sp')
        ip_row.add_widget(self._send_suffix_input)

        self._scan_btn = Button(text='扫描', font_name=DEFAULT_FONT,
                                size_hint_x=0.18,
                                on_press=self._scan_network)
        ip_row.add_widget(self._scan_btn)
        layout.add_widget(ip_row)

        # ========== 3. 历史目标 ==========
        hist_title = Label(text='历史目标(点一下直接填入):',
                           font_name=DEFAULT_FONT, size_hint_y=None, height=20,
                           color=(0.5, 0.5, 0.5, 1), font_size='12sp')
        layout.add_widget(hist_title)

        hist_scroll = ScrollView(size_hint_y=None, height=88,
                                 do_scroll_x=False)
        self._hist_grid = GridLayout(cols=3, spacing=4,
                                     size_hint_y=None, height=0)
        self._hist_grid.bind(minimum_height=self._hist_grid.setter('height'))
        hist_scroll.add_widget(self._hist_grid)
        layout.add_widget(hist_scroll)

        # ========== 4. 待发送信息 + 进度条 ==========
        self._send_info_label = Label(
            text=f'待发送 {len(paths)} 个文件',
            font_name=DEFAULT_FONT, size_hint_y=None, height=24,
            color=(0.55, 0.25, 0.65, 1), font_size='13sp')
        layout.add_widget(self._send_info_label)

        self._send_progress = ProgressBar(max=100, value=0,
                                          size_hint_y=None, height=18)
        layout.add_widget(self._send_progress)

        # ========== 5. 弹性占位:把上面所有内容顶到顶部 ==========
        layout.add_widget(Widget())

        # ========== 6. 底部按钮(固定在底部) ==========
        btn_row = BoxLayout(size_hint_y=None, height=50, spacing=8)
        btn_cancel = Button(text='取消', font_name=DEFAULT_FONT,
                            on_press=lambda x: self._close_send_popup())
        self._send_btn = Button(
            text='开始发送', font_name=DEFAULT_FONT,
            on_press=lambda x: self._do_send_files(paths, on_finished))
        btn_row.add_widget(btn_cancel)
        btn_row.add_widget(self._send_btn)
        layout.add_widget(btn_row)

        self._send_popup.content = layout
        self._refresh_history_buttons()
        self._send_popup.open()

    def _close_send_popup(self):
        try:
            if self._send_popup:
                self._send_popup.dismiss()
        except Exception:
            pass

    def _refresh_history_buttons(self):
        if not hasattr(self, '_hist_grid'):
            return
        self._hist_grid.clear_widgets()

        recent = self._settings.get('recent_targets', [])
        if not isinstance(recent, list):
            recent = []

        if not recent:
            self._hist_grid.add_widget(Label(
                text='(暂无历史)', font_name=DEFAULT_FONT,
                size_hint_y=None, height=30, color=(0.6, 0.6, 0.6, 1)))
            return

        prefix = self._send_prefix_label.text.rstrip('.')
        for ip in recent:
            label = ip
            if ip.startswith(prefix + '.'):
                label = ip.split('.')[-1] + '  ★'
            btn = Button(text=label, font_name=DEFAULT_FONT,
                         size_hint_y=None, height=36)
            btn.bind(on_press=lambda x, target=ip: self._pick_history_ip(target))
            self._hist_grid.add_widget(btn)

    def _pick_history_ip(self, ip):
        prefix = self._send_prefix_label.text.rstrip('.')
        if ip.startswith(prefix + '.'):
            self._send_suffix_input.text = ip.split('.')[-1]
        else:
            self._send_suffix_input.text = ip.split('.')[-1]
            show_popup('提示',
                       f'该历史 IP 为 {ip}\n与当前网段 {prefix}.x 不同。\n'
                       f'已填入最后一段,请确认是否可用。')

    def _scan_network(self, instance):
        prefix = self._send_prefix_label.text.rstrip('.')
        if prefix.count('.') != 2:
            show_popup('提示', '无法确定当前网段')
            return

        self._scan_btn.disabled = True
        self._scan_btn.text = '扫描中...'
        self._hist_grid.clear_widgets()
        self._hist_grid.add_widget(Label(
            text='正在扫描 1~254...', font_name=DEFAULT_FONT,
            size_hint_y=None, height=30, color=(0.4, 0.4, 0.4, 1)))

        threading.Thread(target=self._scan_worker,
                         args=(prefix, self.PORT), daemon=True).start()

    def _scan_worker(self, prefix, port):
        found = []
        lock = threading.Lock()

        def probe(last):
            ip = f'{prefix}.{last}'
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.settimeout(0.4)
            try:
                s.connect((ip, port))
                with lock:
                    found.append(ip)
            except Exception:
                pass
            finally:
                try:
                    s.close()
                except Exception:
                    pass

        try:
            with ThreadPoolExecutor(max_workers=64) as ex:
                list(ex.map(probe, range(1, 255)))
        except Exception as e:
            Logger.warning(f'扫描失败: {e}')

        found.sort(key=lambda x: int(x.split('.')[-1]))
        Clock.schedule_once(lambda dt: self._show_scan_results(found), 0)

    def _show_scan_results(self, ips):
        try:
            self._scan_btn.disabled = False
            self._scan_btn.text = '扫描'
        except Exception:
            pass

        if not hasattr(self, '_hist_grid'):
            return
        self._hist_grid.clear_widgets()

        if not ips:
            self._hist_grid.add_widget(Label(
                text='未发现设备(对方要先启动接收服务)',
                font_name=DEFAULT_FONT, size_hint_y=None, height=30,
                color=(0.7, 0.2, 0.2, 1)))
            return

        recent = self._settings.get('recent_targets', [])
        if not isinstance(recent, list):
            recent = []

        for ip in ips:
            mark = '★ ' if ip in recent else '  '
            btn = Button(text=mark + ip, font_name=DEFAULT_FONT,
                         size_hint_y=None, height=36)
            btn.bind(on_press=lambda x, target=ip: self._pick_scanned_ip(target))
            self._hist_grid.add_widget(btn)

    def _pick_scanned_ip(self, ip):
        prefix = self._send_prefix_label.text.rstrip('.')
        if ip.startswith(prefix + '.'):
            self._send_suffix_input.text = ip.split('.')[-1]

    def _do_send_files(self, paths, on_finished=None):
        suffix = (self._send_suffix_input.text or '').strip()
        if not suffix:
            show_popup('提示', '请输入对方 IP 的最后一段')
            return
        try:
            last = int(suffix)
            if last < 0 or last > 255:
                raise ValueError
        except Exception:
            show_popup('提示', 'IP 最后一段需要是 0~255 的数字')
            return

        prefix = self._send_prefix_label.text.rstrip('.')
        if prefix.count('.') != 2:
            show_popup('提示', '无法确定本机网段')
            return
        target_ip = f'{prefix}.{last}'

        valid_paths = [p for p in paths
                       if os.path.isfile(p) and os.path.getsize(p) > 0]
        if not valid_paths:
            show_popup('提示', '要发送的文件都已失效')
            return

        self._add_recent_target(target_ip)

        self._send_btn.disabled = True
        self._send_btn.text = '发送中...'
        self._send_progress.value = 0

        threading.Thread(target=self._send_worker,
                         args=(target_ip, list(valid_paths), on_finished),
                         daemon=True).start()

    def _send_worker(self, target_ip, paths, on_finished=None):
        total = len(paths)
        ok, failed = 0, []

        def set_info(text):
            Clock.schedule_once(
                lambda dt: setattr(self._send_info_label, 'text', text), 0)

        for idx, path in enumerate(paths, 1):
            name = os.path.basename(path)
            set_info(f'[{idx}/{total}] {name}  →  {target_ip}:{self.PORT}')
            self._update_progress(0)

            if self._send_one(target_ip, path):
                ok += 1
                try:
                    self.db.mark_sent(path, target_ip)
                    self._sent_paths_cache.add(path)
                except Exception as e:
                    Logger.warning(f'记录发送结果失败: {e}')
            else:
                failed.append(path)

        def finalize(dt):
            try:
                self._send_btn.disabled = False
                self._send_btn.text = '开始发送'
            except Exception:
                pass

            if failed:
                msg = f'成功 {ok}/{total} 个,失败 {len(failed)} 个\n\n失败文件:\n'
                msg += '\n'.join(os.path.basename(p) for p in failed[:10])
                if len(failed) > 10:
                    msg += f'\n... 还有 {len(failed) - 10} 个'
                show_popup('发送完成', msg)
            else:
                show_popup('发送完成', f'全部 {total} 个文件发送成功')

            self._close_send_popup()
            if on_finished:
                try:
                    on_finished()
                except Exception as e:
                    Logger.warning(f'on_finished 执行失败: {e}')

        Clock.schedule_once(finalize, 0)

    def _send_one(self, target_ip, path):
        try:
            size = os.path.getsize(path)
        except Exception as e:
            Logger.error(f'读取失败 {path}: {e}')
            return False

        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(15)
        try:
            sock.connect((target_ip, self.PORT))
            sock.settimeout(None)

            name = os.path.basename(path)
            header = f'{name}|{size}'.encode('utf-8')
            header = header[:self.HEADER_SIZE].ljust(self.HEADER_SIZE, b' ')
            sock.sendall(header)

            sent = 0
            last_pct = -1
            with open(path, 'rb') as f:
                while sent < size:
                    chunk = f.read(self.CHUNK)
                    if not chunk:
                        break
                    sock.sendall(chunk)
                    sent += len(chunk)
                    pct = int(sent * 100 / size) if size else 100
                    if pct != last_pct:
                        last_pct = pct
                        self._update_progress(pct)

            self._update_progress(100)
            return True

        except Exception as e:
            Logger.error(f'发送失败 {path}: {e}')
            return False
        finally:
            try:
                sock.close()
            except Exception:
                pass

    def _update_progress(self, pct):
        def _do(dt):
            try:
                if self._send_progress:
                    self._send_progress.value = max(0, min(100, int(pct)))
            except Exception:
                pass
        Clock.schedule_once(_do, 0)

    # =================================================================
    #  目录选择
    # =================================================================
    def show_dir_chooser(self, instance):
        self._dir_popup = Popup(title='选择浏览目录', size_hint=(0.9, 0.8))
        layout = BoxLayout(orientation='vertical', spacing=5)

        self._dir_path_label = Label(text='当前: /storage/emulated/0',
                                     font_name=DEFAULT_FONT,
                                     size_hint_y=0.1)
        layout.add_widget(self._dir_path_label)

        scroll = ScrollView()
        self._dir_grid = GridLayout(cols=1, spacing=2, size_hint_y=None)
        self._dir_grid.bind(minimum_height=self._dir_grid.setter('height'))
        scroll.add_widget(self._dir_grid)
        layout.add_widget(scroll)

        btn_box = BoxLayout(size_hint_y=0.1, spacing=5)
        btn_up = Button(text='返回上级', font_name=DEFAULT_FONT)
        btn_cancel = Button(text='取消', font_name=DEFAULT_FONT,
                            on_press=self._dir_popup.dismiss)
        btn_ok = Button(text='选择此目录', font_name=DEFAULT_FONT)
        btn_box.add_widget(btn_up)
        btn_box.add_widget(btn_cancel)
        btn_box.add_widget(btn_ok)
        layout.add_widget(btn_box)

        self._dir_popup.content = layout

        def update_list(path):
            self._dir_path_label.text = f'当前: {path}'
            self._dir_grid.clear_widgets()
            try:
                items = [d for d in os.listdir(path)
                         if os.path.isdir(os.path.join(path, d))
                         and not d.startswith('.')]
                items.sort()
                for d in items:
                    subpath = os.path.join(path, d)
                    btn = Button(text=d, font_name=DEFAULT_FONT,
                                 size_hint_y=None, height=40)
                    btn.bind(on_press=lambda btn, p=subpath: update_list(p))
                    self._dir_grid.add_widget(btn)
            except PermissionError:
                show_popup('权限错误', '无法读取目录,请检查存储权限')

            btn_ok.bind(on_press=lambda x: self.set_current_dir(path))
            if path == '/storage/emulated/0':
                btn_up.bind(
                    on_press=lambda x: update_list('/storage/emulated/0'))
            else:
                parent = os.path.dirname(path)
                btn_up.bind(on_press=lambda x: update_list(parent)
                            if parent != path else None)

        update_list('/storage/emulated/0')
        self._dir_popup.open()

    def set_current_dir(self, path):
        if os.path.exists(path) and os.path.isdir(path):
            self.current_dir = path
            self.dir_label.text = f'目录: {path}'
            self.selected_paths.clear()
            self.image_paths = []
            self.current_page = 0
            self.tag_filter = None
            threading.Thread(target=self.scan_images,
                             args=(path,), daemon=True).start()
            if self._dir_popup:
                self._dir_popup.dismiss()
        else:
            show_popup('错误', '所选目录无效或不存在')


if __name__ == '__main__':
    PhotoBrowserApp().run()

上面是main.py文件代码。整个目录有4.68G。

python 复制代码
# -*- coding: utf-8 -*-
"""
局域网分享 · PC 端
- 接收服务:监听 8888 端口,接收单个/批量文件
- 发送:可多选文件 / 整个文件夹,一键批量发送
- 协议与手机端一致:1024 字节 header("文件名|大小") + 64KB 数据块
- 【新增】接收成功的日志条目可点击直接打开文件
- 【新增】日志条目右键可在文件管理器中定位
"""

import os
import re
import sys
import socket
import threading
import time
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from datetime import datetime


class LanSharePC:
    PORT = 8888
    CHUNK = 64 * 1024          # 64KB
    HEADER_SIZE = 1024

    def __init__(self, root):
        self.root = root
        self.root.title("局域网分享 · PC 端")
        self.root.geometry("880x760")
        self.root.minsize(760, 640)

        # ---------- 状态 ----------
        self.server_running = False
        self.server_socket = None
        self.server_thread = None

        self.pending_files = []    # [{"path":..., "name":..., "size":...}, ...]
        self.sending = False

        # 日志中可点击 tag 的计数器
        self._log_tag_counter = 0

        self.receive_dir = tk.StringVar(value=self._default_receive_dir())
        self.ip_prefix = tk.StringVar()
        self.ip_suffix = tk.StringVar()

        # ---------- 样式 ----------
        style = ttk.Style()
        try:
            style.theme_use("clam")
        except Exception:
            pass
        style.configure("OK.TLabel", foreground="#2E7D32",
                        font=("Microsoft YaHei UI", 10, "bold"))
        style.configure("Hint.TLabel", foreground="#666666",
                        font=("Microsoft YaHei UI", 9))

        self._build_ui()
        self._refresh_local_ip()
        self._refresh_pending_ui()

        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    # =================================================================
    #  UI
    # =================================================================
    def _build_ui(self):
        main = ttk.Frame(self.root, padding=10)
        main.pack(fill="both", expand=True)

        # ---------------- 接收服务 ----------------
        top = ttk.LabelFrame(main, text="① 接收服务", padding=8)
        top.pack(fill="x")

        r1 = ttk.Frame(top); r1.pack(fill="x", pady=2)
        ttk.Label(r1, text="本机 IP:").pack(side="left")
        self.local_ip_label = ttk.Label(r1, text="获取中...", style="OK.TLabel")
        self.local_ip_label.pack(side="left")
        ttk.Label(r1, text="  (告诉对方这个 IP,端口 8888)",
                  style="Hint.TLabel").pack(side="left")

        r2 = ttk.Frame(top); r2.pack(fill="x", pady=2)
        self.server_btn = ttk.Button(r2, text="启动接收服务",
                                     command=self._toggle_server, width=16)
        self.server_btn.pack(side="left")
        self.server_status = ttk.Label(r2, text="未启动", style="Hint.TLabel")
        self.server_status.pack(side="left", padx=8)

        r3 = ttk.Frame(top); r3.pack(fill="x", pady=2)
        ttk.Label(r3, text="接收目录:").pack(side="left")
        ttk.Entry(r3, textvariable=self.receive_dir).pack(
            side="left", fill="x", expand=True, padx=2)
        ttk.Button(r3, text="浏览...", width=8,
                   command=self._choose_receive_dir).pack(side="left")
        ttk.Button(r3, text="打开", width=6,
                   command=self._open_receive_dir).pack(side="left", padx=2)

        r4 = ttk.Frame(top); r4.pack(fill="x", pady=(4, 0))
        ttk.Label(r4, text="接收进度:").pack(side="left")
        self.receive_progress = ttk.Progressbar(r4, mode="determinate", maximum=100)
        self.receive_progress.pack(side="left", fill="x", expand=True, padx=4)
        self.receive_pct = ttk.Label(r4, text="  0%", width=6)
        self.receive_pct.pack(side="left")

        # ---------------- 待发送列表 ----------------
        mid = ttk.LabelFrame(main, text="② 待发送文件", padding=8)
        mid.pack(fill="both", expand=True, pady=(8, 0))

        btn_row = ttk.Frame(mid); btn_row.pack(fill="x")
        ttk.Button(btn_row, text="添加文件...", command=self._add_files).pack(side="left")
        ttk.Button(btn_row, text="添加文件夹...", command=self._add_folder).pack(side="left", padx=4)
        ttk.Button(btn_row, text="移除选中", command=self._remove_selected).pack(side="left")
        ttk.Button(btn_row, text="清空", command=self._clear_pending).pack(side="left", padx=4)
        self.summary_label = ttk.Label(btn_row, text="共 0 个文件", style="Hint.TLabel")
        self.summary_label.pack(side="right")

        tree_frame = ttk.Frame(mid)
        tree_frame.pack(fill="both", expand=True, pady=(6, 0))

        cols = ("name", "size", "path")
        self.tree = ttk.Treeview(tree_frame, columns=cols, show="headings",
                                 selectmode="extended", height=8)
        self.tree.heading("name", text="文件名")
        self.tree.heading("size", text="大小")
        self.tree.heading("path", text="路径")
        self.tree.column("name", width=220, anchor="w")
        self.tree.column("size", width=90, anchor="e")
        self.tree.column("path", width=440, anchor="w")

        vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=vsb.set)
        self.tree.pack(side="left", fill="both", expand=True)
        vsb.pack(side="right", fill="y")

        # 双击待发送列表里的文件也能打开本地文件(方便预览)
        self.tree.bind("<Double-1>", self._on_pending_double_click)

        # ---------------- 发送 ----------------
        send_frame = ttk.LabelFrame(main, text="③ 发送", padding=8)
        send_frame.pack(fill="x", pady=(8, 0))

        ip_row = ttk.Frame(send_frame); ip_row.pack(fill="x")
        ttk.Label(ip_row, text="对方 IP:").pack(side="left")
        ttk.Entry(ip_row, textvariable=self.ip_prefix, width=15,
                  state="readonly").pack(side="left")
        ttk.Label(ip_row, text=".").pack(side="left")
        ttk.Entry(ip_row, textvariable=self.ip_suffix, width=6).pack(side="left", padx=(0, 6))
        ttk.Label(ip_row, text="(只填最后一段,如 100)",
                  style="Hint.TLabel").pack(side="left")

        self.send_btn = ttk.Button(ip_row, text="发送全部文件",
                                   command=self._start_send, width=16)
        self.send_btn.pack(side="right")

        prog_row = ttk.Frame(send_frame); prog_row.pack(fill="x", pady=(6, 0))
        ttk.Label(prog_row, text="发送进度:").pack(side="left")
        self.send_progress = ttk.Progressbar(prog_row, mode="determinate", maximum=100)
        self.send_progress.pack(side="left", fill="x", expand=True, padx=4)
        self.send_pct = ttk.Label(prog_row, text="  0%", width=6)
        self.send_pct.pack(side="left")

        # ---------------- 日志 ----------------
        log_frame = ttk.LabelFrame(main, text="日志", padding=6)
        log_frame.pack(fill="both", expand=True, pady=(8, 0))

        # 提示条
        hint_row = ttk.Frame(log_frame)
        hint_row.pack(fill="x", pady=(0, 4))
        ttk.Label(hint_row,
                  text="💡 蓝色下划线的日志条目可【单击打开文件】;【右键】可在文件管理器中定位",
                  style="Hint.TLabel").pack(side="left")

        text_wrap = ttk.Frame(log_frame)
        text_wrap.pack(fill="both", expand=True)

        self.log_text = tk.Text(text_wrap, height=8, wrap="word",
                                state="disabled", font=("Consolas", 9),
                                cursor="arrow")
        log_vsb = ttk.Scrollbar(text_wrap, orient="vertical",
                                command=self.log_text.yview)
        self.log_text.configure(yscrollcommand=log_vsb.set)
        self.log_text.pack(side="left", fill="both", expand=True)
        log_vsb.pack(side="right", fill="y")

        # 全局 tag(样式和右键都用同一个前缀)
        self.log_text.tag_configure("__hint", foreground="#888888")
        # 我们为每个可点击条目动态创建 tag,命名统一以 "__file_" 开头
        # 右键菜单
        self._log_menu = tk.Menu(self.root, tearoff=0)
        self._log_menu.add_command(label="打开文件", command=self._log_menu_open)
        self._log_menu.add_command(label="在文件管理器中定位", command=self._log_menu_reveal)
        self._log_menu.add_separator()
        self._log_menu.add_command(label="复制路径", command=self._log_menu_copy)
        self.log_text.bind("<Button-3>", self._on_log_right_click)
        # 记录右键时的路径
        self._log_menu_path = None

    # =================================================================
    #  小工具
    # =================================================================
    def _default_receive_dir(self):
        d = os.path.join(os.path.expanduser("~"), "Downloads", "LANShare")
        try:
            os.makedirs(d, exist_ok=True)
        except Exception:
            d = os.path.expanduser("~")
        return d

    def _choose_receive_dir(self):
        d = filedialog.askdirectory(title="选择接收目录",
                                    initialdir=self.receive_dir.get() or os.path.expanduser("~"))
        if d:
            self.receive_dir.set(d)

    def _open_receive_dir(self):
        d = self.receive_dir.get()
        if not os.path.isdir(d):
            messagebox.showwarning("提示", "接收目录不存在")
            return
        self._open_path(d)

    def _refresh_local_ip(self):
        def worker():
            ip = "无法获取"
            try:
                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                s.settimeout(2)
                s.connect(("8.8.8.8", 80))
                ip = s.getsockname()[0]
                s.close()
            except Exception:
                try:
                    ip = socket.gethostbyname(socket.gethostname())
                except Exception:
                    pass
            self.root.after(0, lambda: self._apply_local_ip(ip))
        threading.Thread(target=worker, daemon=True).start()

    def _apply_local_ip(self, ip):
        self.local_ip_label.config(text=ip)
        if ip.count(".") == 3:
            self.ip_prefix.set(".".join(ip.split(".")[:3]))

    @staticmethod
    def _format_size(size):
        if size <= 0:
            return "0 B"
        s = float(size)
        for unit in ("B", "KB", "MB", "GB"):
            if s < 1024.0:
                return f"{s:.1f} {unit}"
            s /= 1024.0
        return f"{s:.1f} TB"

    # ----------------------------------------------------------------
    #  打开 / 定位 / 复制
    # ----------------------------------------------------------------
    def _open_path(self, path):
        """用系统默认程序打开文件或目录"""
        if not os.path.exists(path):
            messagebox.showwarning("提示", f"路径不存在:\n{path}")
            return
        try:
            if os.name == "nt":
                os.startfile(path)
            elif sys.platform == "darwin":
                import subprocess
                subprocess.Popen(["open", path])
            else:
                import subprocess
                subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("打开失败", f"{path}\n\n{e}")

    def _reveal_path(self, path):
        """在系统文件管理器中定位文件"""
        if not os.path.exists(path):
            messagebox.showwarning("提示", f"路径不存在:\n{path}")
            return
        try:
            if os.name == "nt":
                import subprocess
                subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
            elif sys.platform == "darwin":
                import subprocess
                subprocess.Popen(["open", "-R", path])
            else:
                import subprocess
                subprocess.Popen(["xdg-open", os.path.dirname(path)])
        except Exception as e:
            messagebox.showerror("定位失败", str(e))

    # ----------------------------------------------------------------
    #  日志(支持可点击)
    # ----------------------------------------------------------------
    def log(self, msg, clickable_path=None):
        """
        输出一条日志。
        - 普通调用:log("xxx")
        - 可点击:log("xxx", clickable_path="/path/to/file")
          该行会以蓝色+下划线显示,单击打开,右键出菜单。
        """
        def _do():
            ts = datetime.now().strftime("%H:%M:%S")
            self.log_text.configure(state="normal")

            prefix = f"[{ts}] "
            line = f"{msg}\n"

            if clickable_path and os.path.exists(clickable_path):
                # 为这条日志单独创建一个 tag
                tag = f"__file_{self._log_tag_counter}"
                self._log_tag_counter += 1

                # 时间戳部分用普通样式
                self.log_text.insert("end", prefix)
                # 消息部分用 tag
                self.log_text.insert("end", line, (tag,))

                # 样式
                self.log_text.tag_configure(
                    tag, foreground="#1565C0", underline=True)

                # 单击打开
                self.log_text.tag_bind(
                    tag, "<Button-1>",
                    lambda e, p=clickable_path: self._open_path(p))
                # 右键出菜单
                self.log_text.tag_bind(
                    tag, "<Button-3>",
                    lambda e, p=clickable_path: self._show_log_menu(e, p))
                # 悬停指针变手型
                self.log_text.tag_bind(
                    tag, "<Enter>",
                    lambda e: self.log_text.configure(cursor="hand2"))
                self.log_text.tag_bind(
                    tag, "<Leave>",
                    lambda e: self.log_text.configure(cursor="arrow"))
            else:
                self.log_text.insert("end", prefix + line)

            self.log_text.see("end")
            self.log_text.configure(state="disabled")
        try:
            self.root.after(0, _do)
        except Exception:
            pass

    # 右键菜单相关
    def _show_log_menu(self, event, path):
        self._log_menu_path = path
        try:
            self._log_menu.tk_popup(event.x_root, event.y_root)
        finally:
            try:
                self._log_menu.grab_release()
            except Exception:
                pass

    def _on_log_right_click(self, event):
        """右键点在非 tag 区域时提示一下"""
        # 判断点击位置有没有 tag
        try:
            idx = self.log_text.index(f"@{event.x},{event.y}")
            tags = self.log_text.tag_names(idx)
        except Exception:
            tags = ()
        file_tags = [t for t in tags if t.startswith("__file_")]
        if not file_tags:
            # 点在普通文本上,不弹菜单
            return
        # 有 tag 的情况交给 tag_bind 处理,这里不重复弹
        # (避免二次弹出)
        return "break"

    def _log_menu_open(self):
        if self._log_menu_path:
            self._open_path(self._log_menu_path)

    def _log_menu_reveal(self):
        if self._log_menu_path:
            self._reveal_path(self._log_menu_path)

    def _log_menu_copy(self):
        if not self._log_menu_path:
            return
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(self._log_menu_path)
            self.log("已复制路径到剪贴板")
        except Exception as e:
            messagebox.showerror("复制失败", str(e))

    def _set_progress(self, bar, pct_label, value):
        value = max(0, min(100, int(value)))
        def _do():
            bar["value"] = value
            pct_label.config(text=f"{value:3d}%")
        try:
            self.root.after(0, _do)
        except Exception:
            pass

    # =================================================================
    #  待发送列表管理
    # =================================================================
    def _add_files(self):
        if self.sending:
            return
        paths = filedialog.askopenfilenames(title="选择要发送的文件")
        if not paths:
            return
        added = 0
        for p in paths:
            p = os.path.abspath(p)
            if any(f["path"] == p for f in self.pending_files):
                continue
            try:
                size = os.path.getsize(p)
            except Exception:
                size = 0
            self.pending_files.append({
                "path": p,
                "name": os.path.basename(p),
                "size": size,
            })
            added += 1
        if added:
            self.log(f"添加了 {added} 个文件")
        self._refresh_pending_ui()

    def _add_folder(self):
        if self.sending:
            return
        d = filedialog.askdirectory(title="选择文件夹(其内文件将全部加入)")
        if not d:
            return
        added = 0
        for root_dir, _, files in os.walk(d):
            for name in files:
                p = os.path.abspath(os.path.join(root_dir, name))
                if any(f["path"] == p for f in self.pending_files):
                    continue
                try:
                    size = os.path.getsize(p)
                except Exception:
                    size = 0
                self.pending_files.append({
                    "path": p,
                    "name": os.path.basename(p),
                    "size": size,
                })
                added += 1
        if added:
            self.log(f"从文件夹添加了 {added} 个文件")
        self._refresh_pending_ui()

    def _remove_selected(self):
        if self.sending:
            return
        sel = self.tree.selection()
        if not sel:
            return
        indexes = sorted((int(i) for i in sel), reverse=True)
        for i in indexes:
            if 0 <= i < len(self.pending_files):
                self.pending_files.pop(i)
        self._refresh_pending_ui()

    def _clear_pending(self):
        if self.sending:
            return
        if not self.pending_files:
            return
        self.pending_files = []
        self._refresh_pending_ui()
        self.log("待发送列表已清空")

    def _refresh_pending_ui(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        total_size = 0
        for i, f in enumerate(self.pending_files):
            total_size += f["size"]
            self.tree.insert("", "end", iid=str(i),
                             values=(f["name"], self._format_size(f["size"]), f["path"]))

        count = len(self.pending_files)
        if count:
            self.summary_label.config(
                text=f"共 {count} 个文件 · 合计 {self._format_size(total_size)}")
        else:
            self.summary_label.config(text="共 0 个文件")

        state = "normal" if (count and not self.sending) else "disabled"
        try:
            self.send_btn.config(state=state)
        except Exception:
            pass

    def _on_pending_double_click(self, event):
        """双击待发送列表里的某一行 → 打开该文件"""
        item = self.tree.identify_row(event.y)
        if not item:
            return
        try:
            idx = int(item)
            if 0 <= idx < len(self.pending_files):
                self._open_path(self.pending_files[idx]["path"])
        except Exception:
            pass

    # =================================================================
    #  接收服务
    # =================================================================
    def _toggle_server(self):
        if self.server_running:
            self._stop_server()
        else:
            self._start_server()

    def _start_server(self):
        if self.server_running:
            return
        self.server_running = True
        self.server_btn.config(text="停止接收服务")
        self.server_status.config(text="监听 0.0.0.0:8888")
        self.server_thread = threading.Thread(target=self._server_loop, daemon=True)
        self.server_thread.start()

    def _stop_server(self):
        self.server_running = False
        try:
            if self.server_socket:
                self.server_socket.close()
        except Exception:
            pass
        self.server_socket = None
        self.server_btn.config(text="启动接收服务")
        self.server_status.config(text="未启动")
        self.log("接收服务已停止")

    def _server_loop(self):
        srv = None
        try:
            srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            srv.bind(("0.0.0.0", self.PORT))
            srv.listen(5)
            srv.settimeout(1.0)
            self.server_socket = srv
            self.log(f"接收服务已启动,监听端口 {self.PORT}")

            while self.server_running:
                try:
                    conn, addr = srv.accept()
                except socket.timeout:
                    continue
                except OSError:
                    break

                self.log(f"收到来自 {addr[0]}:{addr[1]} 的连接")
                t = threading.Thread(target=self._handle_client,
                                     args=(conn, addr), daemon=True)
                t.start()

        except Exception as e:
            self.log(f"服务启动失败: {e}")
        finally:
            self.server_running = False
            if srv:
                try:
                    srv.close()
                except Exception:
                    pass
            self.server_socket = None
            try:
                self.root.after(0, lambda: self.server_btn.config(text="启动接收服务"))
                self.root.after(0, lambda: self.server_status.config(text="未启动"))
            except Exception:
                pass

    @staticmethod
    def _recv_exact(conn, n):
        buf = b""
        while len(buf) < n:
            try:
                chunk = conn.recv(n - len(buf))
            except Exception:
                break
            if not chunk:
                break
            buf += chunk
        return buf

    def _handle_client(self, conn, addr):
        try:
            header = self._recv_exact(conn, self.HEADER_SIZE)
            if not header:
                self.log(f"{addr[0]} 未发送文件信息")
                return

            text = header.decode("utf-8", errors="ignore").strip()
            if "|" not in text:
                self.log(f"文件信息格式错误: {text[:80]!r}")
                return

            name, _, size_str = text.rpartition("|")
            name = os.path.basename(name.strip())
            if not name:
                name = f"received_{int(time.time())}"

            try:
                size = int(size_str)
            except ValueError:
                self.log(f"文件大小无效: {size_str}")
                return

            save_dir = self.receive_dir.get()
            try:
                os.makedirs(save_dir, exist_ok=True)
            except Exception:
                save_dir = self._default_receive_dir()
                self.receive_dir.set(save_dir)

            path = os.path.join(save_dir, name)
            base, ext = os.path.splitext(name)
            i = 1
            while os.path.exists(path):
                path = os.path.join(save_dir, f"{base}({i}){ext}")
                i += 1

            self.log(f"开始接收 {name}({self._format_size(size)})")
            self._set_progress(self.receive_progress, self.receive_pct, 0)

            received = 0
            last_pct = -1
            t0 = time.time()
            with open(path, "wb") as f:
                while received < size:
                    want = min(self.CHUNK, size - received)
                    chunk = conn.recv(want)
                    if not chunk:
                        break
                    f.write(chunk)
                    received += len(chunk)
                    pct = int(received * 100 / size) if size else 100
                    if pct != last_pct:
                        last_pct = pct
                        self._set_progress(self.receive_progress, self.receive_pct, pct)

            elapsed = time.time() - t0
            speed = (received / 1024 / 1024 / elapsed) if elapsed > 0 else 0
            self._set_progress(self.receive_progress, self.receive_pct, 100)

            if received == size:
                # ★ 关键改动:把 path 传进 log,使日志条目可点击
                self.log(f"  ✓ 已保存 {name} · {elapsed:.2f}s · "
                         f"{speed:.2f} MB/s → {path}",
                         clickable_path=path)
            else:
                # 不完整也允许点开看一下(一般是残缺文件,用户想确认)
                self.log(f"  ⚠ 接收不完整 {name}({received}/{size} 字节)→ {path}",
                         clickable_path=path)

        except Exception as e:
            self.log(f"接收失败: {e}")
        finally:
            try:
                conn.close()
            except Exception:
                pass

    # =================================================================
    #  批量发送
    # =================================================================
    def _start_send(self):
        if self.sending:
            return
        prefix = (self.ip_prefix.get() or "").strip()
        suffix = (self.ip_suffix.get() or "").strip()

        if not prefix or not suffix:
            messagebox.showwarning("提示", "请输入对方 IP 的最后一段")
            return

        target = f"{prefix}.{suffix}"
        if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", target):
            messagebox.showwarning("提示", "IP 格式不正确")
            return
        if any(int(p) > 255 for p in target.split(".")):
            messagebox.showwarning("提示", "IP 每段不能大于 255")
            return

        if not self.pending_files:
            messagebox.showwarning("提示", "待发送列表为空")
            return

        self.sending = True
        self.send_btn.config(state="disabled", text="发送中...")
        self._set_progress(self.send_progress, self.send_pct, 0)

        items = list(self.pending_files)
        threading.Thread(target=self._send_worker,
                         args=(target, items), daemon=True).start()

    def _send_worker(self, target_ip, items):
        total = len(items)
        ok, failed = [], []

        self.log(f"===== 开始批量发送:{total} 个文件 → {target_ip}:{self.PORT} =====")

        for idx, item in enumerate(items, 1):
            path = item["path"]
            name = item["name"]

            if not os.path.exists(path):
                self.log(f"[{idx}/{total}] 跳过(文件不存在):{name}")
                failed.append(item)
                continue

            self.log(f"[{idx}/{total}] 发送 {name} ...")
            self._set_progress(self.send_progress, self.send_pct, 0)

            if self._send_one(target_ip, path, name):
                ok.append(item)
            else:
                failed.append(item)

        def _finalize():
            self.sending = False
            self.send_btn.config(text="发送全部文件")
            self.pending_files = failed
            self._refresh_pending_ui()
            self.log(f"===== 批量发送完成:成功 {len(ok)}/{total},失败 {len(failed)} =====")

        self.root.after(0, _finalize)

    def _send_one(self, target_ip, path, name):
        try:
            size = os.path.getsize(path)
        except Exception as e:
            self.log(f"  ✗ 无法读取 {name}: {e}")
            return False

        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(15)
        try:
            sock.connect((target_ip, self.PORT))
            sock.settimeout(None)

            header = f"{name}|{size}".encode("utf-8")
            header = header[:self.HEADER_SIZE].ljust(self.HEADER_SIZE, b" ")
            sock.sendall(header)

            sent = 0
            last_pct = -1
            t0 = time.time()
            with open(path, "rb") as f:
                while sent < size:
                    chunk = f.read(self.CHUNK)
                    if not chunk:
                        break
                    sock.sendall(chunk)
                    sent += len(chunk)
                    pct = int(sent * 100 / size) if size else 100
                    if pct != last_pct:
                        last_pct = pct
                        self._set_progress(self.send_progress, self.send_pct, pct)

            elapsed = time.time() - t0
            speed = (sent / 1024 / 1024 / elapsed) if elapsed > 0 else 0
            self.log(f"  ✓ {name} · {self._format_size(size)} · "
                     f"{elapsed:.2f}s · {speed:.2f} MB/s")
            return True

        except Exception as e:
            self.log(f"  ✗ 发送 {name} 失败: {e}")
            return False
        finally:
            try:
                sock.close()
            except Exception:
                pass

    # =================================================================
    def _on_close(self):
        try:
            if self.server_running:
                self._stop_server()
        except Exception:
            pass
        self.root.destroy()


def main():
    root = tk.Tk()
    LanSharePC(root)
    root.mainloop()


if __name__ == "__main__":
    main()

上面是局域网分享 · PC 端

python 复制代码
# -*- coding: utf-8 -*-
"""
局域网分享 · 数据库小图查看器 v3
====================================
v3 更新:
1. 每页 5×2 = 10 张(原来是 3×3 = 9 张)
2. 卡片尺寸缩小以适配 5 列布局
3. 文件名 / 标签截断长度相应调整
"""

import os
import io
import json
import sqlite3
import sys
import threading
from datetime import datetime

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

try:
    from PIL import Image, ImageTk
except ImportError:
    raise SystemExit("需要 Pillow 库:pip install pillow")


# =====================================================================
#  常量
# =====================================================================
COLS = 5                    # ← 5 列
PAGE_SIZE = 10              # ← 每页 10 张(5×2)
THUMB_DISPLAY = (140, 140)  # 缩略图显示尺寸(比 v2 小一点)
CARD_WIDTH = 170            # 卡片宽
CARD_HEIGHT = 225           # 卡片高

COLOR_OK = "#4CAF50"
COLOR_MISS = "#BDBDBD"
COLOR_TEXT_OK = "#2E7D32"
COLOR_TEXT_MISS = "#9E9E9E"
COLOR_TAG = "#7B1FA2"
COLOR_HIGHLIGHT = "#FF9800"

FILTER_ALL = "__ALL__"
FILTER_UNTAGGED = "__UNTAGGED__"


# =====================================================================
#  主程序
# =====================================================================
class DbThumbViewer:

    def __init__(self, root):
        self.root = root
        self.root.title("局域网分享 · 数据库小图查看器")
        self.root.geometry("1100x820")
        self.root.minsize(960, 680)

        # ---------- 状态 ----------
        self.db_path = None
        self.original_dir = None
        self.original_map = {}

        self.all_items = []
        self.items = []
        self.send_records = {}
        self.sent_images = {}

        self.tag_counts = {}
        self.filter_tag = FILTER_ALL

        self.current_page = 0
        self.total_pages = 1
        self._photo_refs = []

        self.settings_path = os.path.join(
            os.path.expanduser("~"), ".lanshare_viewer.json")
        self.settings = self._load_settings()

        self._build_ui()

        last_dir = self.settings.get("last_dir")
        if last_dir and os.path.isdir(last_dir):
            self.original_dir = last_dir
            self.original_dir_var.set(last_dir)
            threading.Thread(target=self._scan_dir_worker,
                             args=(last_dir,), daemon=True).start()
        else:
            self._render_grid()

        last_db = self.settings.get("last_db")
        if last_db and os.path.isfile(last_db):
            self.root.after(120, lambda: self._load_db(last_db))

        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    # =================================================================
    def _load_settings(self):
        try:
            if os.path.isfile(self.settings_path):
                with open(self.settings_path, "r", encoding="utf-8") as f:
                    return json.load(f) or {}
        except Exception:
            pass
        return {}

    def _save_settings(self):
        try:
            with open(self.settings_path, "w", encoding="utf-8") as f:
                json.dump(self.settings, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    # =================================================================
    #  UI
    # =================================================================
    def _build_ui(self):
        tb = ttk.Frame(self.root, padding=8)
        tb.pack(fill="x")

        ttk.Button(tb, text="📂 打开数据库...",
                   command=self._choose_db).pack(side="left")
        ttk.Button(tb, text="🖼 选择原图目录...",
                   command=self._choose_original_dir).pack(side="left", padx=6)
        ttk.Button(tb, text="🔄 刷新",
                   command=self._refresh).pack(side="left")

        self.stat_label = ttk.Label(tb, text="", foreground="#2E7D32")
        self.stat_label.pack(side="right")

        info_row = ttk.Frame(self.root, padding=(8, 0, 8, 2))
        info_row.pack(fill="x")
        ttk.Label(info_row, text="数据库:", foreground="#555555").pack(side="left")
        self.db_label = ttk.Label(info_row, text="(未加载)",
                                  foreground="#1565C0")
        self.db_label.pack(side="left")

        dir_row = ttk.Frame(self.root, padding=(8, 0, 8, 4))
        dir_row.pack(fill="x")
        ttk.Label(dir_row, text="原图目录:", foreground="#555555").pack(side="left")
        self.original_dir_var = tk.StringVar(value="(未选择)")
        ttk.Label(dir_row, textvariable=self.original_dir_var,
                  foreground="#1565C0").pack(side="left")

        # ---------- 分类筛选 ----------
        self.filter_frame = ttk.LabelFrame(self.root, text="分类筛选",
                                            padding=6)
        self.filter_frame.pack(fill="x", padx=8, pady=(4, 0))

        self.filter_inner = ttk.Frame(self.filter_frame)
        self.filter_inner.pack(fill="x")

        self.filter_hint = ttk.Label(
            self.filter_inner, text="(打开数据库后显示分类)",
            foreground="#888888")
        self.filter_hint.pack(side="left", padx=4)

        # ---------- 提示 ----------
        hint_row = ttk.Frame(self.root, padding=(8, 4, 8, 0))
        hint_row.pack(fill="x")
        ttk.Label(hint_row,
                  text="💡 绿框=有原图 / 灰框=仅缩略图  |  "
                       "双击打开原图,单击查看详情,右键更多操作",
                  foreground="#888888").pack(side="left")

        # ---------- 九宫格滚动区 ----------
        body = ttk.Frame(self.root, padding=(8, 6, 8, 0))
        body.pack(fill="both", expand=True)

        self.canvas = tk.Canvas(body, borderwidth=0, background="#F5F5F5",
                                highlightthickness=0)
        vscroll = ttk.Scrollbar(body, orient="vertical",
                                command=self.canvas.yview)
        self.canvas.configure(yscrollcommand=vscroll.set)
        self.canvas.pack(side="left", fill="both", expand=True)
        vscroll.pack(side="right", fill="y")

        self.grid_frame = ttk.Frame(self.canvas)
        self.grid_window = self.canvas.create_window(
            (0, 0), window=self.grid_frame, anchor="nw")

        self.grid_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(
                scrollregion=self.canvas.bbox("all")))
        self.canvas.bind(
            "<Configure>",
            lambda e: self.canvas.itemconfigure(self.grid_window, width=e.width))
        self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)

        # ---------- 分页 ----------
        pager = ttk.Frame(self.root, padding=8)
        pager.pack(fill="x")
        self.prev_btn = ttk.Button(pager, text="◀ 上一页",
                                   command=self._prev_page)
        self.prev_btn.pack(side="left")
        self.page_label = ttk.Label(pager, text="第 0/0 页")
        self.page_label.pack(side="left", padx=12)
        self.next_btn = ttk.Button(pager, text="下一页 ▶",
                                   command=self._next_page)
        self.next_btn.pack(side="left")

    def _on_mousewheel(self, event):
        try:
            if sys.platform == "darwin":
                self.canvas.yview_scroll(-1 * event.delta, "units")
            else:
                self.canvas.yview_scroll(int(-event.delta / 120), "units")
        except Exception:
            pass

    # =================================================================
    #  打开数据库
    # =================================================================
    def _choose_db(self):
        path = filedialog.askopenfilename(
            title="选择数据库文件",
            filetypes=[("SQLite 数据库", "*.db *.sqlite *.sqlite3"),
                       ("所有文件", "*.*")])
        if path:
            self._load_db(path)

    def _load_db(self, path):
        try:
            conn = sqlite3.connect(path)
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'")
            tables = {r[0] for r in cur.fetchall()}

            if "thumbnails" not in tables:
                conn.close()
                messagebox.showerror(
                    "打开失败",
                    "该数据库里没有 thumbnails 表。\n"
                    "请确认这是从 App 导出的 lanshare.db 文件。")
                return

            # ---------- 缩略图(按 mtime 倒序,新图在前) ----------
            rows = conn.execute(
                "SELECT path, mtime, data FROM thumbnails "
                "ORDER BY mtime DESC").fetchall()

            # ---------- 标签映射 ----------
            tags_per_image = {}
            tag_counts = {}
            if "tags" in tables and "image_tags" in tables:
                try:
                    cur = conn.execute('''
                        SELECT it.path, t.name
                        FROM image_tags it
                        JOIN tags t ON t.id = it.tag_id
                        ORDER BY t.name
                    ''')
                    for p, name in cur.fetchall():
                        tags_per_image.setdefault(p, []).append(name)
                        tag_counts[name] = tag_counts.get(name, 0) + 1
                except Exception as e:
                    print(f"读取标签失败: {e}")

            items = []
            for p, m, data in rows:
                if not data:
                    continue
                items.append({
                    "path": p,
                    "mtime": m,
                    "data": data,
                    "original": None,
                    "tags": tags_per_image.get(p, []),
                })

            # 按 mtime 倒序(新图在前);mtime 为空时视为 0 排在最后
            items.sort(key=lambda x: x.get("mtime") or 0, reverse=True)

            # ---------- 发送记录 ----------
            send_records = {}
            if "send_records" in tables:
                cur = conn.execute(
                    "SELECT path, ip, sent_at FROM send_records "
                    "ORDER BY sent_at DESC")
                for p, ip, ts in cur.fetchall():
                    send_records.setdefault(p, []).append((ip, ts))

            sent_images = {}
            if "sent_images" in tables:
                cur = conn.execute(
                    "SELECT path, first_sent_at, last_sent_at, send_count "
                    "FROM sent_images")
                for p, f, l, c in cur.fetchall():
                    sent_images[p] = {"first": f, "last": l, "count": c}

            conn.close()

            self.db_path = path
            self.all_items = items
            self.send_records = send_records
            self.sent_images = sent_images
            self.tag_counts = tag_counts
            self.filter_tag = FILTER_ALL

            self._match_originals()
            self._rebuild_filter_ui()
            self._apply_filter()

            self.db_label.config(text=path)

            self.settings["last_db"] = path
            self._save_settings()

        except Exception as e:
            messagebox.showerror("打开失败", str(e))

    # =================================================================
    #  分类筛选
    # =================================================================
    def _rebuild_filter_ui(self):
        for w in self.filter_inner.winfo_children():
            w.destroy()

        if not self.all_items:
            ttk.Label(self.filter_inner,
                      text="(打开数据库后显示分类)",
                      foreground="#888888").pack(side="left", padx=4)
            return

        total = len(self.all_items)
        tagged = sum(1 for it in self.all_items if it.get("tags"))
        untagged = total - tagged

        container = ttk.Frame(self.filter_inner)
        container.pack(fill="x")

        # 每行按钮数(窗口加宽后可以多放)
        MAX_PER_ROW = 8
        row = 0
        col = 0

        def add_button(text, cmd, highlight=False):
            nonlocal row, col
            style = "Highlight.TButton" if highlight else "TButton"
            btn = ttk.Button(container, text=text, command=cmd,
                             style=style, width=14)
            btn.grid(row=row, column=col, padx=3, pady=3, sticky="ew")
            col += 1
            if col >= MAX_PER_ROW:
                col = 0
                row += 1

        add_button(f"全部 ({total})",
                   lambda: self._set_filter(FILTER_ALL),
                   highlight=(self.filter_tag == FILTER_ALL))

        add_button(f"未分类 ({untagged})",
                   lambda: self._set_filter(FILTER_UNTAGGED),
                   highlight=(self.filter_tag == FILTER_UNTAGGED))

        for name in sorted(self.tag_counts.keys()):
            cnt = self.tag_counts[name]
            add_button(f"{name} ({cnt})",
                       lambda n=name: self._set_filter(n),
                       highlight=(self.filter_tag == name))

        for c in range(MAX_PER_ROW):
            container.columnconfigure(c, weight=1)

        style = ttk.Style()
        try:
            style.configure("Highlight.TButton",
                            foreground="#FFFFFF",
                            background="#FF9800",
                            font=("Microsoft YaHei UI", 9, "bold"))
            style.map("Highlight.TButton",
                      background=[("active", "#F57C00")])
        except Exception:
            pass

    def _set_filter(self, tag):
        self.filter_tag = tag
        self.current_page = 0
        self._rebuild_filter_ui()
        self._apply_filter()

    def _apply_filter(self):
        if not self.all_items:
            self.items = []
        elif self.filter_tag == FILTER_ALL:
            self.items = list(self.all_items)
        elif self.filter_tag == FILTER_UNTAGGED:
            self.items = [it for it in self.all_items if not it.get("tags")]
        else:
            self.items = [it for it in self.all_items
                          if self.filter_tag in (it.get("tags") or [])]

        self.current_page = 0
        self._update_pager()
        self._render_grid()
        self._update_status()

    def _update_status(self):
        n_total = len(self.all_items)
        n_show = len(self.items)
        n_orig = sum(1 for it in self.items if it.get("original"))
        n_tag = len(self.tag_counts)

        if n_total == 0:
            self.stat_label.config(text="共 0 张缩略图",
                                   foreground="#757575")
            return

        if self.filter_tag == FILTER_ALL:
            prefix = f"全部 {n_total} 张"
        elif self.filter_tag == FILTER_UNTAGGED:
            prefix = f"未分类 {n_show} 张"
        else:
            prefix = f"「{self.filter_tag}」 {n_show} 张"

        parts = [prefix, f"有原图 {n_orig} 张", f"标签 {n_tag} 个"]
        self.stat_label.config(text="  ·  ".join(parts),
                               foreground="#2E7D32" if n_show else "#9E9E9E")

    # =================================================================
    #  原图目录
    # =================================================================
    def _choose_original_dir(self):
        d = filedialog.askdirectory(
            title="选择包含原图的目录(通常是 LANShare 接收目录)",
            initialdir=self.original_dir or os.path.expanduser("~"))
        if not d:
            return
        self.original_dir = d
        self.original_dir_var.set(d)
        self.settings["last_dir"] = d
        self._save_settings()

        self.stat_label.config(text="正在扫描原图目录...",
                               foreground="#FF9800")
        threading.Thread(target=self._scan_dir_worker,
                         args=(d,), daemon=True).start()

    def _scan_dir_worker(self, d):
        mapping = {}
        try:
            for root_dir, _, files in os.walk(d):
                for f in files:
                    key = f.lower()
                    mapping.setdefault(key, os.path.join(root_dir, f))
        except Exception as e:
            print(f"扫描失败: {e}")
        self.root.after(0, lambda: self._after_scan(mapping))

    def _after_scan(self, mapping):
        self.original_map = mapping
        self._match_originals()
        self._render_grid()
        self._update_status()

    def _match_originals(self):
        for item in self.all_items:
            item["original"] = self._find_original(
                os.path.basename(item["path"]))

    def _find_original(self, basename):
        if not basename:
            return None
        key = basename.lower()
        if key in self.original_map:
            return self.original_map[key]
        stem, ext = os.path.splitext(key)
        for i in range(1, 20):
            cand = f"{stem}({i}){ext}"
            if cand in self.original_map:
                return self.original_map[cand]
        return None

    # =================================================================
    #  卡片渲染
    # =================================================================
    def _render_grid(self):
        for w in self.grid_frame.winfo_children():
            w.destroy()
        self._photo_refs.clear()

        if not self.items:
            if not self.all_items:
                msg = "(没有缩略图,请先打开一个从 App 导出的数据库)"
            else:
                msg = "(当前分类下没有图片)"
            ttk.Label(self.grid_frame, text=msg,
                      foreground="#888888",
                      font=("Microsoft YaHei UI", 11)).grid(
                row=0, column=0, padx=30, pady=30)
            return

        start = self.current_page * PAGE_SIZE
        end = min(start + PAGE_SIZE, len(self.items))
        page_items = self.items[start:end]

        # 用 grid 布局,5 列
        for idx, item in enumerate(page_items):
            row = idx // COLS
            col = idx % COLS
            card = self._make_card(item)
            card.grid(row=row, column=col, padx=6, pady=6, sticky="n")

        # 让 5 列平均分配宽度
        for c in range(COLS):
            self.grid_frame.columnconfigure(c, weight=1)

    def _make_card(self, item):
        has_original = bool(item.get("original"))
        border_color = COLOR_OK if has_original else COLOR_MISS

        outer = tk.Frame(self.grid_frame, bg=border_color,
                         width=CARD_WIDTH, height=CARD_HEIGHT)
        outer.pack_propagate(False)

        inner = tk.Frame(outer, bg="#FFFFFF")
        inner.place(x=2, y=2, relwidth=1, relheight=1,
                    width=-4, height=-4)

        # 缩略图
        try:
            img = Image.open(io.BytesIO(item["data"]))
            if img.mode not in ("RGB", "RGBA"):
                img = img.convert("RGB")
            img.thumbnail(THUMB_DISPLAY, Image.Resampling.LANCZOS)
            photo = ImageTk.PhotoImage(img)
            self._photo_refs.append(photo)
            img_label = tk.Label(inner, image=photo, bg="#FFFFFF",
                                 cursor="hand2")
        except Exception as e:
            img_label = tk.Label(inner, text="(无法解析)",
                                 bg="#FFFFFF", fg="#B71C1C",
                                 width=16, height=6)
            print(f"缩略图解析失败 {item['path']}: {e}")

        img_label.pack(pady=(4, 2))

        # 文件名(卡片窄了,截断到 15 字符)
        name = os.path.basename(item["path"]) or "(未知)"
        display = name if len(name) <= 15 else (name[:7] + "..." + name[-7:])
        name_label = tk.Label(inner, text=display, bg="#FFFFFF",
                              fg="#333333",
                              font=("Microsoft YaHei UI", 8),
                              wraplength=CARD_WIDTH - 12)
        name_label.pack(pady=(0, 1))

        # 标签行
        tags = item.get("tags") or []
        if tags:
            tag_text = " · ".join(tags)
            if len(tag_text) > 14:
                tag_text = tag_text[:13] + "..."
            tag_label = tk.Label(inner, text="🏷 " + tag_text,
                                 bg="#FFFFFF", fg=COLOR_TAG,
                                 font=("Microsoft YaHei UI", 7),
                                 wraplength=CARD_WIDTH - 12)
            tag_label.pack(pady=(0, 1))
        else:
            tag_label = tk.Label(inner, text="(未分类)",
                                 bg="#FFFFFF", fg="#AAAAAA",
                                 font=("Microsoft YaHei UI", 7))
            tag_label.pack(pady=(0, 1))

        # 状态
        status_text = "✓ 有原图" if has_original else "○ 仅缩略图"
        fg = COLOR_TEXT_OK if has_original else COLOR_TEXT_MISS
        status_label = tk.Label(inner, text=status_text, bg="#FFFFFF", fg=fg,
                                font=("Microsoft YaHei UI", 7, "bold"))
        status_label.pack(pady=(0, 3))

        # 事件
        def _open(e, it=item):
            self._open_original(it)
            return "break"

        def _menu(e, it=item):
            self._show_card_menu(e, it)
            return "break"

        def _info(e, it=item):
            self._show_item_info(it)

        for w in (outer, inner, img_label, name_label,
                  tag_label, status_label):
            w.bind("<Double-Button-1>", _open)
            w.bind("<Button-3>", _menu)
            w.bind("<Button-1>", _info)

        return outer

    # =================================================================
    #  分页
    # =================================================================
    def _update_pager(self):
        n = len(self.items)
        if n == 0:
            self.total_pages = 1
        else:
            self.total_pages = (n + PAGE_SIZE - 1) // PAGE_SIZE

        if self.current_page >= self.total_pages:
            self.current_page = self.total_pages - 1
        if self.current_page < 0:
            self.current_page = 0

        self.page_label.config(
            text=f"第 {self.current_page + 1}/{self.total_pages} 页")
        self.prev_btn.config(
            state="normal" if self.current_page > 0 else "disabled")
        self.next_btn.config(
            state="normal" if self.current_page < self.total_pages - 1
            else "disabled")

    def _prev_page(self):
        if self.current_page > 0:
            self.current_page -= 1
            self._update_pager()
            self._render_grid()
            self.canvas.yview_moveto(0)

    def _next_page(self):
        if self.current_page < self.total_pages - 1:
            self.current_page += 1
            self._update_pager()
            self._render_grid()
            self.canvas.yview_moveto(0)

    def _refresh(self):
        if self.db_path and os.path.isfile(self.db_path):
            self._load_db(self.db_path)
        else:
            self._render_grid()

    # =================================================================
    #  详情 / 打开
    # =================================================================
    def _show_item_info(self, item):
        path = item["path"]
        name = os.path.basename(path)
        lines = [f"文件名: {name}", f"手机路径: {path}"]

        original = item.get("original")
        if original:
            lines.append(f"本地原图: {original}")
            try:
                lines.append(
                    f"文件大小: {self._fmt_size(os.path.getsize(original))}")
            except Exception:
                pass
        else:
            lines.append("本地原图: 未找到")

        tags = item.get("tags") or []
        if tags:
            lines.append("")
            lines.append(f"标签({len(tags)} 个): " + " / ".join(tags))
        else:
            lines.append("")
            lines.append("标签: (无)")

        info = self.sent_images.get(path)
        if info:
            lines.append("")
            lines.append(f"发送次数: {info.get('count', 0)}")
            lines.append(f"首次发送: {self._fmt_ts(info.get('first'))}")
            lines.append(f"最后发送: {self._fmt_ts(info.get('last'))}")

        records = self.send_records.get(path, [])
        if records:
            lines.append("")
            lines.append(f"发送目标(共 {len(records)} 次):")
            for ip, ts in records[:10]:
                lines.append(f"  → {ip}   {self._fmt_ts(ts)}")
            if len(records) > 10:
                lines.append(f"  ... 还有 {len(records) - 10} 条")

        messagebox.showinfo("图片详情", "\n".join(lines))

    def _open_original(self, item):
        path = item.get("original")
        if path and os.path.isfile(path):
            self._open_path(path)
            return

        name = os.path.basename(item["path"])
        ans = messagebox.askyesno(
            "未找到原图",
            f"本地没有找到该图片的原图:\n\n{name}\n\n"
            f"是否现在选择一个目录来查找原图?\n"
            f"(通常是电脑的 LANShare 接收目录)")
        if ans:
            self._choose_original_dir()

    def _show_card_menu(self, event, item):
        menu = tk.Menu(self.root, tearoff=0)
        original = item.get("original")

        if original:
            menu.add_command(label="打开原图",
                             command=lambda: self._open_path(original))
            menu.add_command(label="在文件管理器中定位",
                             command=lambda: self._reveal_path(original))
        else:
            menu.add_command(label="(无原图)", state="disabled")

        menu.add_separator()

        tags = item.get("tags") or []
        if tags:
            tag_menu = tk.Menu(menu, tearoff=0)
            for t in tags:
                tag_menu.add_command(
                    label=f"只看「{t}」",
                    command=lambda n=t: self._set_filter(n))
            menu.add_cascade(label="按标签筛选", menu=tag_menu)
        else:
            menu.add_command(label="标为未分类 → 只看未分类",
                             command=lambda: self._set_filter(FILTER_UNTAGGED))

        menu.add_command(label="复制手机路径",
                         command=lambda: self._copy_to_clipboard(item["path"]))
        menu.add_command(label="查看详情",
                         command=lambda: self._show_item_info(item))

        try:
            menu.tk_popup(event.x_root, event.y_root)
        finally:
            try:
                menu.grab_release()
            except Exception:
                pass

    # =================================================================
    #  工具
    # =================================================================
    def _copy_to_clipboard(self, text):
        try:
            self.root.clipboard_clear()
            self.root.clipboard_append(text)
            self.stat_label.config(text="已复制到剪贴板",
                                   foreground="#1565C0")
            self.root.after(1500, self._update_status)
        except Exception as e:
            messagebox.showerror("复制失败", str(e))

    def _open_path(self, path):
        if not os.path.exists(path):
            messagebox.showwarning("提示", f"文件不存在:\n{path}")
            return
        try:
            if os.name == "nt":
                os.startfile(path)
            elif sys.platform == "darwin":
                import subprocess
                subprocess.Popen(["open", path])
            else:
                import subprocess
                subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("打开失败", str(e))

    def _reveal_path(self, path):
        if not os.path.exists(path):
            messagebox.showwarning("提示", f"文件不存在:\n{path}")
            return
        try:
            if os.name == "nt":
                import subprocess
                subprocess.Popen(
                    ["explorer", "/select,", os.path.normpath(path)])
            elif sys.platform == "darwin":
                import subprocess
                subprocess.Popen(["open", "-R", path])
            else:
                import subprocess
                subprocess.Popen(["xdg-open", os.path.dirname(path)])
        except Exception as e:
            messagebox.showerror("定位失败", str(e))

    @staticmethod
    def _fmt_size(size):
        if size <= 0:
            return "0 B"
        s = float(size)
        for unit in ("B", "KB", "MB", "GB"):
            if s < 1024.0:
                return f"{s:.1f} {unit}"
            s /= 1024.0
        return f"{s:.1f} TB"

    @staticmethod
    def _fmt_ts(ts):
        if not ts:
            return "-"
        try:
            return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
        except Exception:
            return str(ts)

    def _on_close(self):
        try:
            self._save_settings()
        except Exception:
            pass
        self.root.destroy()


def main():
    root = tk.Tk()
    DbThumbViewer(root)
    root.mainloop()


if __name__ == "__main__":
    main()

上面是数据库小图查看,可以把手机的所有图片放在一个数据库上发到电脑查看。

相关推荐
企业数字化笔记1 小时前
固定资产还有借用记录能报废吗?Java前置检查、停止折旧与SQL验收
java·开发语言·sql
ctlover1 小时前
hot-100刷题笔记
数据结构·python
三岁就很~酷~1 小时前
ai开发 python+claudecode环境搭建
python·ai编程
赵钰老师1 小时前
基于ArcGIS Pro、R、INVEST等多技术融合下生态系统服务权衡与协同动态分析
python·arcgis·数据分析·r语言
言乐61 小时前
Python:用AES-GCM算法加密链接内容,并生成可分享的加密链接和解密工具
python·django·virtualenv·pygame·tornado
小静AI工程实验室2 小时前
Claude Code 实用教程:Windows、macOS、Linux 从安装到项目调试与验收
人工智能·python·开发工具·claude code
beijixinghe2 小时前
第15节 指针作为函数参数的工程实战用法
开发语言·c++·c++基础·c++入门·几何引擎c++
SEO_juper2 小时前
2026年用Python分析网站访问日志:看清Googlebot和AI爬虫怎么爬你的站(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
OKkankan2 小时前
Python常用容器与导入语法详解(二)
数据结构·python