fcbyk-cli 0.0.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # 官方文档 https://github.com/github-linguist/linguist/blob/master/docs/overrides.md
2
+
3
+ web/** linguist-generated=true
@@ -0,0 +1,34 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: [3.8, 3.9, '3.10', '3.11', '3.12']
15
+
16
+ steps:
17
+ - uses: actions/checkout@v3
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v4
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Install dependencies
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install -e ".[test]"
26
+ - name: Run tests with coverage
27
+ run: |
28
+ pytest --cov=fcbyk --cov-report=xml
29
+ - name: Upload coverage to Codecov
30
+ uses: codecov/codecov-action@v3
31
+ with:
32
+ file: ./coverage.xml
33
+ fail_ci_if_error: true
34
+ token: ${{ secrets.CODECOV_TOKEN }}
@@ -0,0 +1,9 @@
1
+ *.egg-info/
2
+ build/
3
+ dist/
4
+ .idea
5
+
6
+ __pycache__
7
+ .coverage
8
+ .pytest_cache
9
+ .vscode
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yoki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: fcbyk-cli
3
+ Version: 0.0.0a1
4
+ License-File: LICENSE
5
+ Requires-Python: >=3.7
6
+ Requires-Dist: click
7
+ Requires-Dist: flask
8
+ Requires-Dist: pyperclip
9
+ Provides-Extra: test
10
+ Requires-Dist: beautifulsoup4; extra == 'test'
11
+ Requires-Dist: pytest; extra == 'test'
12
+ Requires-Dist: pytest-cov; extra == 'test'
@@ -0,0 +1,4 @@
1
+ [![PyPI](https://img.shields.io/pypi/v/fcbyk-cli.svg)](https://pypi.org/project/fcbyk-cli/)
2
+ [![Tests](https://github.com/fcbyk/fcbyk-cli/actions/workflows/test.yml/badge.svg)](https://github.com/fcbyk/fcbyk-cli/actions/workflows/test.yml)
3
+ [![codecov](https://codecov.io/gh/fcbyk/fcbyk-cli/branch/main/graph/badge.svg)](https://codecov.io/gh/fcbyk/fcbyk-cli)
4
+ [![License](https://img.shields.io/github/license/fcbyk/fcbyk-cli.svg)](https://github.com/fcbyk/fcbyk-cli/blob/main/LICENSE)
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fcbyk-cli"
7
+ version = "0.0.0-alpha.1"
8
+ requires-python = ">=3.7"
9
+ dependencies = [
10
+ "click",
11
+ "flask",
12
+ "pyperclip"
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ test = [
17
+ "pytest",
18
+ "pytest-cov",
19
+ "beautifulsoup4"
20
+ ]
21
+
22
+ [project.scripts]
23
+ fcbyk = "fcbyk.cli:cli"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/fcbyk"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["src/fcbyk/tests"]
30
+ python_files = ["test_*.py"]
31
+ addopts = "-v --cov=fcbyk --cov-report=term-missing"
File without changes
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+ import click
3
+ import logging
4
+ import importlib.metadata
5
+ import sys
6
+
7
+ # 禁用 Flask 的日志
8
+ log = logging.getLogger('werkzeug')
9
+ log.setLevel(logging.ERROR)
10
+
11
+ from .commands import lansend
12
+
13
+ def print_version(ctx, param, value):
14
+ if not value or ctx.resilient_parsing:
15
+ return
16
+ version = importlib.metadata.version("fcbyk")
17
+ click.echo(f"v{version}")
18
+ ctx.exit()
19
+
20
+ @click.group(context_settings=dict(help_option_names=['-h', '--help']))
21
+ @click.option('--version', '-v', is_flag=True, callback=print_version, expose_value=False, is_eager=True, help='Show version and exit.')
22
+ def cli():
23
+ pass
24
+
25
+ cli.add_command(lansend)
26
+
27
+ if __name__ == "__main__":
28
+ cli()
@@ -0,0 +1,3 @@
1
+ from .lansend import lansend
2
+
3
+ __all__ = ['lansend']
@@ -0,0 +1,189 @@
1
+ import click
2
+ import os
3
+ import webbrowser
4
+ import pyperclip
5
+ import socket
6
+ from flask import Flask, send_from_directory, abort, render_template, request, jsonify
7
+ import re
8
+
9
+ app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), '..', 'web'))
10
+ shared_directory = None
11
+ display_name = "共享文件夹" # 默认显示名称
12
+ upload_password = None # 上传密码
13
+
14
+ def init_app(directory=None, name=None, password=None):
15
+ global shared_directory, display_name, upload_password
16
+ shared_directory = directory
17
+ if name:
18
+ display_name = name
19
+ if password:
20
+ upload_password = password
21
+
22
+ def safe_filename(filename):
23
+ return re.sub(r'[^\w\s\u4e00-\u9fff\-\.]', '', filename)
24
+
25
+ def get_path_parts(current_path):
26
+ parts = []
27
+ if current_path:
28
+ path_parts = current_path.split('/')
29
+ current = ''
30
+ for part in path_parts:
31
+ if part: # 跳过空的部分
32
+ current = os.path.join(current, part)
33
+ parts.append({
34
+ 'name': part,
35
+ 'path': current
36
+ })
37
+ return parts
38
+
39
+ @app.route('/')
40
+ def index():
41
+ if not shared_directory:
42
+ return "未指定共享目录,请使用 -d 参数指定目录"
43
+ return serve_directory('')
44
+
45
+ @app.route('/<path:filename>')
46
+ def serve_file(filename):
47
+ if not shared_directory:
48
+ abort(404, description="未指定共享目录")
49
+
50
+ file_path = os.path.join(shared_directory, filename)
51
+ if not os.path.exists(file_path):
52
+ abort(404, description="文件不存在")
53
+
54
+ if os.path.isdir(file_path):
55
+ return serve_directory(filename)
56
+
57
+ return send_from_directory(shared_directory, filename)
58
+
59
+ @app.route('/upload', methods=['POST'])
60
+ def upload_file():
61
+ if not shared_directory:
62
+ return jsonify({'error': '未指定共享目录'}), 400
63
+
64
+ if upload_password:
65
+ if 'password' not in request.form:
66
+ return jsonify({'error': '需要上传密码'}), 401
67
+ if request.form['password'] != upload_password:
68
+ return jsonify({'error': '密码错误'}), 401
69
+
70
+ if 'file' not in request.files:
71
+ return jsonify({'error': '没有文件'}), 400
72
+
73
+ file = request.files['file']
74
+ if file.filename == '':
75
+ return jsonify({'error': '没有选择文件'}), 400
76
+
77
+ if file:
78
+ filename = safe_filename(file.filename)
79
+ if not filename:
80
+ filename = '未命名文件'
81
+
82
+ # 检查文件是否已存在
83
+ target_path = os.path.join(shared_directory, filename)
84
+ if os.path.exists(target_path):
85
+ # 生成新文件名
86
+ name, ext = os.path.splitext(filename)
87
+ counter = 1
88
+ while os.path.exists(target_path):
89
+ new_filename = f"{name}_{counter}{ext}"
90
+ target_path = os.path.join(shared_directory, new_filename)
91
+ counter += 1
92
+ filename = new_filename
93
+
94
+ file.save(os.path.join(shared_directory, filename))
95
+ return jsonify({
96
+ 'message': '文件上传成功',
97
+ 'filename': filename,
98
+ 'renamed': counter > 1 if 'counter' in locals() else False
99
+ })
100
+
101
+ return jsonify({'error': '上传失败'}), 500
102
+
103
+ def serve_directory(relative_path):
104
+ current_path = os.path.join(shared_directory, relative_path)
105
+ items = []
106
+
107
+ for name in os.listdir(current_path):
108
+ full_path = os.path.join(current_path, name)
109
+ item_path = os.path.join(relative_path, name)
110
+ items.append({
111
+ 'name': name,
112
+ 'path': item_path,
113
+ 'is_dir': os.path.isdir(full_path)
114
+ })
115
+
116
+ items.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
117
+ share_name = os.path.basename(shared_directory) # 使用实际的文件夹名称作为路径显示
118
+
119
+ return render_template('lansend.html',
120
+ current_path=relative_path or '根目录',
121
+ path_parts=get_path_parts(relative_path),
122
+ items=items,
123
+ share_name=share_name,
124
+ display_name=display_name,
125
+ require_password=bool(upload_password)) # 传递显示名称到模板
126
+
127
+ @click.command(help='Start a local web server for sharing files over LAN')
128
+ @click.option(
129
+ "-p", "--port",
130
+ default=80,
131
+ help="Web server port (default: 80)"
132
+ )
133
+ @click.option(
134
+ "-d", "--directory",
135
+ required=True,
136
+ help="Directory to share (e.g., './files')"
137
+ )
138
+ @click.option(
139
+ "-n", "--name",
140
+ help="Display name for the page title (default: '共享文件夹')"
141
+ )
142
+ @click.option(
143
+ "--password",
144
+ help="Password for file upload (optional)"
145
+ )
146
+ @click.option(
147
+ "--no-browser",
148
+ is_flag=True,
149
+ help="Disable automatic browser opening"
150
+ )
151
+ def lansend(port, directory, name, password, no_browser):
152
+ global shared_directory, display_name, upload_password
153
+
154
+ if not os.path.exists(directory):
155
+ click.echo(f"Error: Directory {directory} does not exist")
156
+ return
157
+
158
+ if not os.path.isdir(directory):
159
+ click.echo(f"Error: {directory} is not a directory")
160
+ return
161
+
162
+ shared_directory = os.path.abspath(directory)
163
+ if name:
164
+ display_name = name
165
+ if password:
166
+ upload_password = password
167
+
168
+ hostname = socket.gethostname()
169
+ local_ip = socket.gethostbyname(hostname)
170
+
171
+ click.echo(f"\n * File Sharing Server")
172
+ click.echo(f" * Directory: {shared_directory}")
173
+ click.echo(f" * Display Name: {display_name}")
174
+ if upload_password:
175
+ click.echo(f" * Upload Password: Enabled")
176
+ click.echo(f" * Local URL: http://localhost:{port}")
177
+ click.echo(f" * Local URL: http://127.0.0.1:{port}")
178
+ click.echo(f" * Network URL: http://{local_ip}:{port}")
179
+
180
+ try:
181
+ pyperclip.copy(f"http://{local_ip}:{port}")
182
+ click.echo(" * URL has been copied to clipboard")
183
+ except:
184
+ click.echo(" * Warning: Could not copy URL to clipboard")
185
+
186
+ if not no_browser:
187
+ webbrowser.open(f"http://{local_ip}:{port}")
188
+
189
+ app.run(host='0.0.0.0', port=port)
@@ -0,0 +1 @@
1
+ # 测试包初始化文件
@@ -0,0 +1,15 @@
1
+ import pytest
2
+ from click.testing import CliRunner
3
+ from ..cli import cli
4
+
5
+ def test_version_command():
6
+ runner = CliRunner()
7
+ result = runner.invoke(cli, ['--version'])
8
+ assert result.exit_code == 0
9
+ assert 'v' in result.output
10
+
11
+ def test_lansend_command_help():
12
+ runner = CliRunner()
13
+ result = runner.invoke(cli, ['lansend', '--help'])
14
+ assert result.exit_code == 0
15
+ assert 'Start a local web server for sharing files over LAN' in result.output
@@ -0,0 +1,33 @@
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+ from ..commands.lansend import app, safe_filename, init_app
5
+
6
+ @pytest.fixture
7
+ def temp_dir():
8
+ with tempfile.TemporaryDirectory() as temp_dir:
9
+ # 创建测试文件
10
+ with open(os.path.join(temp_dir, 'test.txt'), 'w') as f:
11
+ f.write('test content')
12
+ yield temp_dir
13
+
14
+ @pytest.fixture
15
+ def client(temp_dir):
16
+ init_app(directory=temp_dir)
17
+ app.config['TESTING'] = True
18
+ with app.test_client() as client:
19
+ yield client
20
+
21
+ def test_safe_filename():
22
+ assert safe_filename('test.txt') == 'test.txt'
23
+ assert safe_filename('test*file.txt') == 'testfile.txt'
24
+
25
+ def test_index_page(client):
26
+ response = client.get('/')
27
+ assert response.status_code == 200
28
+ assert b'test.txt' in response.data
29
+
30
+ def test_file_download(client):
31
+ response = client.get('/test.txt')
32
+ assert response.status_code == 200
33
+ assert response.data == b'test content'
@@ -0,0 +1,35 @@
1
+ import os
2
+ import tempfile
3
+ import pytest
4
+ from bs4 import BeautifulSoup
5
+ from ..commands.lansend import app, init_app
6
+
7
+ @pytest.fixture
8
+ def temp_dir():
9
+ with tempfile.TemporaryDirectory() as temp_dir:
10
+ # 创建测试文件
11
+ with open(os.path.join(temp_dir, 'test.txt'), 'w') as f:
12
+ f.write('test content')
13
+ yield temp_dir
14
+
15
+ @pytest.fixture
16
+ def client(temp_dir):
17
+ init_app(directory=temp_dir)
18
+ app.config['TESTING'] = True
19
+ with app.test_client() as client:
20
+ yield client
21
+
22
+ def test_template_rendering(client):
23
+ response = client.get('/')
24
+ assert response.status_code == 200
25
+
26
+ soup = BeautifulSoup(response.data, 'html.parser')
27
+ assert soup.title.text == 'LanSend'
28
+
29
+ # 测试文件列表容器
30
+ file_container = soup.find('div', class_='file-container')
31
+ assert file_container is not None
32
+
33
+ # 测试上传容器
34
+ upload_container = soup.find('div', class_='upload-container')
35
+ assert upload_container is not None
@@ -0,0 +1,483 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='1em' font-size='80'>📤</text></svg>" type="image/svg+xml">
7
+ <title>LanSend</title>
8
+ <style>
9
+ * {
10
+ box-sizing: border-box;
11
+ margin: 0;
12
+ padding: 0;
13
+ }
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ background-color: #f5f5f5;
17
+ color: #333;
18
+ padding: 20px 4%;
19
+ width: 100%;
20
+ max-width: 100vw;
21
+ overflow-x: hidden;
22
+ min-height: 100dvh;
23
+ }
24
+ .main-container {
25
+ display: flex;
26
+ flex-direction: row;
27
+ gap: 20px;
28
+ width: 100%;
29
+ max-width: 100%;
30
+ margin: auto;
31
+ height: 90dvh;
32
+ align-items: center;
33
+ }
34
+ .file-container {
35
+ flex: 1;
36
+ background-color: white;
37
+ padding: 20px;
38
+ border-radius: 8px;
39
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
40
+ width: 100%;
41
+ max-width: 100%;
42
+ display: flex;
43
+ flex-direction: column;
44
+ height: 100%;
45
+ overflow: hidden;
46
+ }
47
+ .upload-container {
48
+ flex: 1;
49
+ background-color: white;
50
+ padding: 20px;
51
+ border-radius: 8px;
52
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
53
+ width: 100%;
54
+ max-width: 100%;
55
+ display: flex;
56
+ flex-direction: column;
57
+ height: 100%;
58
+ overflow: hidden;
59
+ }
60
+ h1 {
61
+ color: #2c3e50;
62
+ margin-bottom: 20px;
63
+ font-size: 24px;
64
+ flex-shrink: 0;
65
+ }
66
+ .file-list {
67
+ list-style: none;
68
+ padding: 0;
69
+ width: 100%;
70
+ flex-grow: 1;
71
+ overflow-y: auto;
72
+ overflow-x: hidden;
73
+ margin-right: -10px;
74
+ padding-right: 10px;
75
+ height: calc(90dvh - 100px);
76
+ }
77
+ /* 自定义滚动条样式 */
78
+ .file-list::-webkit-scrollbar {
79
+ width: 8px;
80
+ }
81
+ .file-list::-webkit-scrollbar-track {
82
+ background: #f1f1f1;
83
+ border-radius: 4px;
84
+ }
85
+ .file-list::-webkit-scrollbar-thumb {
86
+ background: #c1c1c1;
87
+ border-radius: 4px;
88
+ }
89
+ .file-list::-webkit-scrollbar-thumb:hover {
90
+ background: #a8a8a8;
91
+ }
92
+ .file-item {
93
+ padding: 10px;
94
+ border-bottom: 1px solid #eee;
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: space-between;
98
+ width: 100%;
99
+ }
100
+ .file-item:hover {
101
+ background-color: #f8f9fa;
102
+ }
103
+ .file-icon {
104
+ margin-right: 10px;
105
+ width: 24px;
106
+ text-align: center;
107
+ flex-shrink: 0;
108
+ }
109
+ .file-name {
110
+ display: flex;
111
+ align-items: center;
112
+ flex-grow: 1;
113
+ min-width: 0;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ white-space: nowrap;
117
+ }
118
+ .file-link {
119
+ color: #3498db;
120
+ text-decoration: none;
121
+ overflow: hidden;
122
+ text-overflow: ellipsis;
123
+ white-space: nowrap;
124
+ }
125
+ .file-link:hover {
126
+ text-decoration: underline;
127
+ }
128
+ .folder-icon {
129
+ color: #f39c12;
130
+ }
131
+ .file-icon {
132
+ color: #3498db;
133
+ }
134
+ .download-btn {
135
+ background-color: #2ecc71;
136
+ color: white;
137
+ border: none;
138
+ padding: 5px 10px;
139
+ border-radius: 4px;
140
+ cursor: pointer;
141
+ text-decoration: none;
142
+ font-size: 12px;
143
+ flex-shrink: 0;
144
+ margin-left: 10px;
145
+ }
146
+ .download-btn:hover {
147
+ background-color: #27ae60;
148
+ }
149
+ .file-info {
150
+ display: flex;
151
+ align-items: center;
152
+ flex-grow: 1;
153
+ min-width: 0;
154
+ overflow: hidden;
155
+ }
156
+ .upload-area {
157
+ border: 2px dashed #3498db;
158
+ border-radius: 8px;
159
+ padding: 20px;
160
+ text-align: center;
161
+ margin-bottom: 20px;
162
+ cursor: pointer;
163
+ transition: all 0.3s;
164
+ height: calc(70dvh - 100px);
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ width: 100%;
169
+ flex-grow: 1;
170
+ }
171
+ .upload-area:hover {
172
+ background-color: #f8f9fa;
173
+ }
174
+ .upload-area.dragover {
175
+ background-color: #e8f4f8;
176
+ border-color: #2980b9;
177
+ }
178
+ .upload-progress {
179
+ margin-top: 10px;
180
+ display: none;
181
+ width: 100%;
182
+ flex-shrink: 0;
183
+ }
184
+ .progress-bar {
185
+ height: 20px;
186
+ background-color: #f0f0f0;
187
+ border-radius: 10px;
188
+ overflow: hidden;
189
+ width: 100%;
190
+ }
191
+ .progress {
192
+ height: 100%;
193
+ background-color: #2ecc71;
194
+ width: 0;
195
+ transition: width 0.3s;
196
+ }
197
+ .upload-status {
198
+ margin-top: 10px;
199
+ font-size: 14px;
200
+ color: #666;
201
+ }
202
+ .upload-hint {
203
+ color: #7f8c8d;
204
+ font-size: 16px;
205
+ }
206
+ .upload-icon {
207
+ font-size: 48px;
208
+ color: #3498db;
209
+ margin-bottom: 10px;
210
+ }
211
+ .password-input {
212
+ margin-top: 15px;
213
+ display: none;
214
+ }
215
+ .password-input input {
216
+ padding: 8px;
217
+ border: 1px solid #ddd;
218
+ border-radius: 4px;
219
+ width: 200px;
220
+ margin-right: 10px;
221
+ }
222
+ .password-input button {
223
+ padding: 8px 15px;
224
+ background-color: #3498db;
225
+ color: white;
226
+ border: none;
227
+ border-radius: 4px;
228
+ cursor: pointer;
229
+ }
230
+ .password-input button:hover {
231
+ background-color: #2980b9;
232
+ }
233
+ .password-error {
234
+ color: #e74c3c;
235
+ margin-top: 5px;
236
+ font-size: 14px;
237
+ display: none;
238
+ }
239
+
240
+ /* 响应式设计 */
241
+ @media (max-width: 768px) {
242
+ body {
243
+ padding: 0;
244
+ margin: 0;
245
+ min-height: 100dvh;
246
+ overflow: hidden;
247
+ }
248
+ .main-container {
249
+ flex-direction: column-reverse;
250
+ gap: 10px;
251
+ height: 100dvh;
252
+ max-width: 100%;
253
+ align-items: stretch;
254
+ margin: 0;
255
+ padding: 10px;
256
+ }
257
+ .file-container, .upload-container {
258
+ padding: 20px;
259
+ height: calc(45dvh - 10px);
260
+ min-height: 180px;
261
+ }
262
+ .file-list {
263
+ height: calc(45dvh - 70px);
264
+ }
265
+ .upload-area {
266
+ height: calc(45dvh - 70px);
267
+ }
268
+ h1 {
269
+ font-size: 18px;
270
+ margin-bottom: 10px;
271
+ }
272
+ }
273
+ </style>
274
+ </head>
275
+ <body>
276
+ <div class="main-container">
277
+ <div class="file-container">
278
+ <h1>{{ display_name }}</h1>
279
+ <div class="current-path" style="margin-bottom: 15px; color: #666; font-size: 14px;">
280
+ <span class="path-separator">/</span>
281
+ {% if path_parts %}
282
+ <a href="/" class="path-link" style="color: #3498db; text-decoration: none;">{{ share_name }}</a>
283
+ <span class="path-separator" style="margin: 0 5px;">/</span>
284
+ {% for part in path_parts %}
285
+ <a href="/{{ part.path }}" class="path-link" style="color: #3498db; text-decoration: none;">{{ part.name }}</a>
286
+ <span class="path-separator" style="margin: 0 5px;">/</span>
287
+ {% endfor %}
288
+ {% else %}
289
+ <a href="/" class="path-link" style="color: #3498db; text-decoration: none;">{{ share_name }}</a>
290
+ <span class="path-separator" style="margin: 0 5px;">/</span>
291
+ {% endif %}
292
+ </div>
293
+
294
+ <ul class="file-list">
295
+ {% for item in items %}
296
+ <li class="file-item">
297
+ <div class="file-info">
298
+ <span class="file-icon">
299
+ {% if item.is_dir %}
300
+ <span class="folder-icon">📁</span>
301
+ {% else %}
302
+ <span class="file-icon">📄</span>
303
+ {% endif %}
304
+ </span>
305
+ <span class="file-name">
306
+ <a href="/{{ item.path }}" class="file-link">{{ item.name }}</a>
307
+ </span>
308
+ </div>
309
+ {% if not item.is_dir %}
310
+ <a href="/{{ item.path }}" class="download-btn" download>下载</a>
311
+ {% endif %}
312
+ </li>
313
+ {% endfor %}
314
+ </ul>
315
+ </div>
316
+
317
+ <div class="upload-container">
318
+ <h1>文件上传</h1>
319
+ <div class="upload-area" id="uploadArea">
320
+ <div>
321
+ <div class="upload-icon">📤</div>
322
+ <p class="upload-hint">拖拽文件到此处或点击选择文件</p>
323
+ </div>
324
+ <input type="file" id="fileInput" multiple style="display: none;">
325
+ </div>
326
+ <div class="password-input" id="passwordInput">
327
+ <input type="password" id="password" placeholder="请输入上传密码">
328
+ <button onclick="verifyPassword()">验证</button>
329
+ <div class="password-error" id="passwordError">密码错误,请重试</div>
330
+ </div>
331
+ <div class="upload-progress" id="uploadProgress">
332
+ <div class="progress-bar">
333
+ <div class="progress" id="progressBar"></div>
334
+ </div>
335
+ <div class="upload-status" id="uploadStatus"></div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+
340
+ <script>
341
+ const uploadArea = document.getElementById('uploadArea');
342
+ const fileInput = document.getElementById('fileInput');
343
+ const uploadProgress = document.getElementById('uploadProgress');
344
+ const progressBar = document.getElementById('progressBar');
345
+ const uploadStatus = document.getElementById('uploadStatus');
346
+ const passwordInput = document.getElementById('passwordInput');
347
+ const passwordField = document.getElementById('password');
348
+ const passwordError = document.getElementById('passwordError');
349
+ let isPasswordVerified = false;
350
+
351
+ // 如果设置了密码,显示密码输入框
352
+ if ({{ require_password|tojson }}) {
353
+ passwordInput.style.display = 'block';
354
+ uploadArea.style.pointerEvents = 'none';
355
+ uploadArea.style.opacity = '0.5';
356
+ }
357
+
358
+ function verifyPassword() {
359
+ const password = passwordField.value;
360
+ if (!password) {
361
+ passwordError.textContent = '请输入密码';
362
+ passwordError.style.display = 'block';
363
+ return;
364
+ }
365
+
366
+ // 发送密码验证请求
367
+ fetch('/upload', {
368
+ method: 'POST',
369
+ headers: {
370
+ 'Content-Type': 'application/x-www-form-urlencoded',
371
+ },
372
+ body: `password=${encodeURIComponent(password)}`
373
+ })
374
+ .then(response => response.json())
375
+ .then(data => {
376
+ if (data.error === '密码错误') {
377
+ passwordError.textContent = '密码错误,请重试';
378
+ passwordError.style.display = 'block';
379
+ } else {
380
+ isPasswordVerified = true;
381
+ passwordInput.style.display = 'none';
382
+ uploadArea.style.pointerEvents = 'auto';
383
+ uploadArea.style.opacity = '1';
384
+ }
385
+ })
386
+ .catch(error => {
387
+ console.error('验证错误:', error);
388
+ passwordError.textContent = '验证失败,请重试';
389
+ passwordError.style.display = 'block';
390
+ });
391
+ }
392
+
393
+ // 拖拽事件处理
394
+ uploadArea.addEventListener('dragover', (e) => {
395
+ if (!isPasswordVerified && {{ require_password|tojson }}) return;
396
+ e.preventDefault();
397
+ uploadArea.classList.add('dragover');
398
+ });
399
+
400
+ uploadArea.addEventListener('dragleave', () => {
401
+ if (!isPasswordVerified && {{ require_password|tojson }}) return;
402
+ uploadArea.classList.remove('dragover');
403
+ });
404
+
405
+ uploadArea.addEventListener('drop', (e) => {
406
+ if (!isPasswordVerified && {{ require_password|tojson }}) return;
407
+ e.preventDefault();
408
+ uploadArea.classList.remove('dragover');
409
+ handleFiles(e.dataTransfer.files);
410
+ });
411
+
412
+ // 点击上传区域
413
+ uploadArea.addEventListener('click', () => {
414
+ if (!isPasswordVerified && {{ require_password|tojson }}) return;
415
+ fileInput.click();
416
+ });
417
+
418
+ fileInput.addEventListener('change', (e) => {
419
+ if (!isPasswordVerified && {{ require_password|tojson }}) return;
420
+ handleFiles(e.target.files);
421
+ });
422
+
423
+ // 处理文件上传
424
+ function handleFiles(files) {
425
+ if (files.length === 0) return;
426
+
427
+ uploadProgress.style.display = 'block';
428
+ let uploaded = 0;
429
+ let renamedFiles = [];
430
+
431
+ Array.from(files).forEach((file, index) => {
432
+ const formData = new FormData();
433
+ formData.append('file', file);
434
+ if ({{ require_password|tojson }}) {
435
+ formData.append('password', passwordField.value);
436
+ }
437
+
438
+ fetch('/upload', {
439
+ method: 'POST',
440
+ body: formData
441
+ })
442
+ .then(response => response.json())
443
+ .then(data => {
444
+ if (data.error) {
445
+ if (data.error === '需要上传密码') {
446
+ passwordInput.style.display = 'block';
447
+ uploadArea.style.pointerEvents = 'none';
448
+ uploadArea.style.opacity = '0.5';
449
+ isPasswordVerified = false;
450
+ }
451
+ uploadStatus.textContent = data.error;
452
+ return;
453
+ }
454
+
455
+ uploaded++;
456
+ const progress = (uploaded / files.length) * 100;
457
+ progressBar.style.width = `${progress}%`;
458
+
459
+ if (data.renamed) {
460
+ renamedFiles.push(data.filename);
461
+ }
462
+
463
+ if (renamedFiles.length > 0) {
464
+ uploadStatus.textContent = `已上传 ${uploaded}/${files.length} 个文件,${renamedFiles.length} 个文件因重名已自动重命名`;
465
+ } else {
466
+ uploadStatus.textContent = `已上传 ${uploaded}/${files.length} 个文件`;
467
+ }
468
+
469
+ if (uploaded === files.length) {
470
+ setTimeout(() => {
471
+ location.reload();
472
+ }, 1000);
473
+ }
474
+ })
475
+ .catch(error => {
476
+ console.error('上传错误:', error);
477
+ uploadStatus.textContent = '上传失败,请重试';
478
+ });
479
+ });
480
+ }
481
+ </script>
482
+ </body>
483
+ </html>