一开始我只想做一个相册浏览器,后来功能越来越多了。
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()
上面是数据库小图查看,可以把手机的所有图片放在一个数据库上发到电脑查看。