重构项目,方便开发
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# frontend-vue dependencies
|
||||||
|
frontend-vue/node_modules/
|
||||||
|
|
||||||
|
# frontend-vue build output
|
||||||
|
frontend-vue/dist/
|
||||||
|
|
||||||
|
# generated type declarations
|
||||||
|
frontend-vue/auto-imports.d.ts
|
||||||
|
frontend-vue/components.d.ts
|
||||||
|
|
||||||
|
# logs
|
||||||
|
frontend-vue/npm-debug.log*
|
||||||
|
frontend-vue/yarn-debug.log*
|
||||||
|
frontend-vue/yarn-error.log*
|
||||||
|
frontend-vue/pnpm-debug.log*
|
||||||
74
desktop/README.md
Normal file
74
desktop/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 桌面端启动
|
||||||
|
|
||||||
|
## Python 要求
|
||||||
|
|
||||||
|
桌面端当前依赖 `pywebview`,在 Windows 下建议使用:
|
||||||
|
|
||||||
|
- `Python 3.12`
|
||||||
|
- `Python 3.11`
|
||||||
|
|
||||||
|
不要直接用 `Python 3.14`。你当前遇到的 `pythonnet` 构建失败就是这个兼容性问题。
|
||||||
|
|
||||||
|
## 开发模式
|
||||||
|
|
||||||
|
前置条件:
|
||||||
|
|
||||||
|
- `frontend-vue` 的 Vite 开发服务已启动在 `http://127.0.0.1:5173`
|
||||||
|
- 后端 API 已启动在 `http://127.0.0.1:8000`
|
||||||
|
- 本机已安装 `Python 3.12` 或 `Python 3.11`
|
||||||
|
|
||||||
|
启动命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd F:\project\crawler-plugin
|
||||||
|
.\desktop\run_desktop_dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:
|
||||||
|
|
||||||
|
- 选择兼容的 Python 解释器
|
||||||
|
- 安装 `desktop/requirements.txt`
|
||||||
|
- 启动 `pywebview` 桌面窗口
|
||||||
|
|
||||||
|
如果脚本检测不到兼容 Python,会直接提示并退出,不再继续错误安装。
|
||||||
|
|
||||||
|
## 发布模式
|
||||||
|
|
||||||
|
先构建前端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd F:\project\crawler-plugin\frontend-vue
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
再启动桌面端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd F:\project\crawler-plugin
|
||||||
|
.\desktop\run_desktop_dist.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 手动安装依赖
|
||||||
|
|
||||||
|
如果要手动安装,请显式使用兼容版本,例如:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.12 -m pip install -r .\desktop\requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前桌面能力
|
||||||
|
|
||||||
|
- 选择品牌 Excel 文件
|
||||||
|
- 选择文件夹
|
||||||
|
- 选择保存目录
|
||||||
|
- 另存模板文件
|
||||||
|
- 按 URL 下载结果文件到本地
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
发布模式下,桌面宿主会:
|
||||||
|
|
||||||
|
- 本地托管 `frontend-vue/dist`
|
||||||
|
- 将 `/api`、`/login`、`/logout`、`/static` 代理到 `--backend-url`
|
||||||
|
|
||||||
|
所以后端仍然需要运行。
|
||||||
1
desktop/__init__.py
Normal file
1
desktop/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
desktop/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
desktop/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
desktop/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/app.cpython-312.pyc
Normal file
BIN
desktop/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/app.cpython-314.pyc
Normal file
BIN
desktop/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
334
desktop/app.py
Normal file
334
desktop/app.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import Tk, filedialog
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
"缺少依赖 requests。\n"
|
||||||
|
"请先执行:python -m pip install -r .\\desktop\\requirements.txt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
import webview
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
"缺少依赖 pywebview。\n"
|
||||||
|
"请先执行:python -m pip install -r .\\desktop\\requirements.txt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
FRONTEND_DIR = ROOT_DIR / "frontend-vue"
|
||||||
|
DIST_DIR = FRONTEND_DIR / "dist"
|
||||||
|
INDEX_FILE = DIST_DIR / "index.html"
|
||||||
|
PROXY_PREFIXES = ("/api", "/login", "/logout", "/static")
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port() -> int:
|
||||||
|
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dist_exists() -> None:
|
||||||
|
if not INDEX_FILE.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"未找到前端构建产物:{INDEX_FILE}\n"
|
||||||
|
"请先进入 frontend-vue 执行 npm run build。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_url(base_url: str, maybe_relative_url: str) -> str:
|
||||||
|
if maybe_relative_url.startswith(("http://", "https://")):
|
||||||
|
return maybe_relative_url
|
||||||
|
return urljoin(base_url.rstrip("/") + "/", maybe_relative_url.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def run_file_dialog(fn):
|
||||||
|
root = Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
try:
|
||||||
|
return fn(root)
|
||||||
|
finally:
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DesktopConfig:
|
||||||
|
mode: str
|
||||||
|
frontend_url: str
|
||||||
|
backend_url: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class DesktopApi:
|
||||||
|
def __init__(self, app_base_url: str) -> None:
|
||||||
|
self.app_base_url = app_base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
def select_brand_xlsx_files(self) -> list[str]:
|
||||||
|
files = run_file_dialog(
|
||||||
|
lambda _root: filedialog.askopenfilenames(
|
||||||
|
title="选择 Excel 文件",
|
||||||
|
filetypes=[("Excel files", "*.xlsx")],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(files)
|
||||||
|
|
||||||
|
def select_brand_folder(self) -> str | None:
|
||||||
|
folder = run_file_dialog(
|
||||||
|
lambda _root: filedialog.askdirectory(title="选择文件夹")
|
||||||
|
)
|
||||||
|
return folder or None
|
||||||
|
|
||||||
|
def select_folder(self) -> str | None:
|
||||||
|
folder = run_file_dialog(
|
||||||
|
lambda _root: filedialog.askdirectory(title="选择文件夹")
|
||||||
|
)
|
||||||
|
return folder or None
|
||||||
|
|
||||||
|
def save_template_xlsx(self) -> dict:
|
||||||
|
return self._download_to_selected_path(
|
||||||
|
"/static/品牌文档格式_模板.xlsx",
|
||||||
|
"品牌文档格式_模板.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_template_zip(self) -> dict:
|
||||||
|
return self._download_to_selected_path(
|
||||||
|
"/static/模板2-以文件夹方式上传.zip",
|
||||||
|
"模板2-以文件夹方式上传.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_file_from_url(self, url: str, filename: str) -> dict:
|
||||||
|
return self._download_to_selected_path(url, filename)
|
||||||
|
|
||||||
|
def _download_to_selected_path(self, source_url: str, default_filename: str) -> dict:
|
||||||
|
path = run_file_dialog(
|
||||||
|
lambda _root: filedialog.asksaveasfilename(
|
||||||
|
title="选择保存位置",
|
||||||
|
initialfile=default_filename,
|
||||||
|
defaultextension=Path(default_filename).suffix or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return {"success": False, "error": "用户取消"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved_url = resolve_url(self.app_base_url, source_url)
|
||||||
|
with self.session.get(resolved_url, timeout=120, stream=True) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with open(path, "wb") as file:
|
||||||
|
for chunk in response.iter_content(chunk_size=1024 * 128):
|
||||||
|
if chunk:
|
||||||
|
file.write(chunk)
|
||||||
|
return {"success": True, "path": path}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
class DistRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
backend_base_url = ""
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
self._dispatch()
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
self._dispatch()
|
||||||
|
|
||||||
|
def do_PUT(self) -> None: # noqa: N802
|
||||||
|
self._dispatch()
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None: # noqa: N802
|
||||||
|
self._dispatch()
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None: # noqa: N802
|
||||||
|
self._dispatch()
|
||||||
|
|
||||||
|
def _dispatch(self) -> None:
|
||||||
|
if self.path.startswith(PROXY_PREFIXES):
|
||||||
|
self._proxy_to_backend()
|
||||||
|
return
|
||||||
|
self._serve_dist_file()
|
||||||
|
|
||||||
|
def _proxy_to_backend(self) -> None:
|
||||||
|
target_url = resolve_url(self.backend_base_url, self.path)
|
||||||
|
body = self._read_request_body()
|
||||||
|
headers = {k: v for k, v in self.headers.items() if k.lower() != "host"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=self.command,
|
||||||
|
url=target_url,
|
||||||
|
headers=headers,
|
||||||
|
data=body,
|
||||||
|
stream=True,
|
||||||
|
timeout=120,
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
payload = f"后端代理请求失败:{exc}".encode("utf-8")
|
||||||
|
self.send_response(502)
|
||||||
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(response.status_code)
|
||||||
|
excluded_headers = {"content-encoding", "transfer-encoding", "connection"}
|
||||||
|
for key, value in response.headers.items():
|
||||||
|
if key.lower() in excluded_headers:
|
||||||
|
continue
|
||||||
|
self.send_header(key, value)
|
||||||
|
self.end_headers()
|
||||||
|
for chunk in response.iter_content(chunk_size=1024 * 128):
|
||||||
|
if chunk:
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
|
||||||
|
def _serve_dist_file(self) -> None:
|
||||||
|
request_path = self.path.split("?", 1)[0].lstrip("/")
|
||||||
|
if not request_path:
|
||||||
|
file_path = INDEX_FILE
|
||||||
|
else:
|
||||||
|
file_path = (DIST_DIR / request_path).resolve()
|
||||||
|
if not str(file_path).startswith(str(DIST_DIR.resolve())):
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
|
if not file_path.exists() or file_path.is_dir():
|
||||||
|
file_path = INDEX_FILE
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", mime_type or "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(len(content)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(content)
|
||||||
|
|
||||||
|
def _read_request_body(self) -> bytes | None:
|
||||||
|
content_length = self.headers.get("Content-Length")
|
||||||
|
if not content_length:
|
||||||
|
return None
|
||||||
|
return self.rfile.read(int(content_length))
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class DistServer:
|
||||||
|
def __init__(self, host: str, port: int, backend_url: str) -> None:
|
||||||
|
handler = type(
|
||||||
|
"ConfiguredDistRequestHandler",
|
||||||
|
(DistRequestHandler,),
|
||||||
|
{"backend_base_url": backend_url.rstrip("/")},
|
||||||
|
)
|
||||||
|
self.server = ThreadingHTTPServer((host, port), handler)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.base_url = f"http://{host}:{port}"
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Crawler Plugin 桌面端启动器")
|
||||||
|
parser.add_argument("--mode", choices=("dev", "dist"), default="dev")
|
||||||
|
parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
|
||||||
|
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=0)
|
||||||
|
parser.add_argument("--width", type=int, default=1440)
|
||||||
|
parser.add_argument("--height", type=int, default=960)
|
||||||
|
parser.add_argument("--title", default="南日AI")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> DesktopConfig:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
port = args.port or find_free_port()
|
||||||
|
return DesktopConfig(
|
||||||
|
mode=args.mode,
|
||||||
|
frontend_url=args.frontend_url.rstrip("/"),
|
||||||
|
backend_url=args.backend_url.rstrip("/"),
|
||||||
|
host=args.host,
|
||||||
|
port=port,
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
title=args.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dev_server_ready(frontend_url: str) -> None:
|
||||||
|
try:
|
||||||
|
response = requests.get(frontend_url, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
"Dev server is not reachable.\n"
|
||||||
|
f"Expected frontend URL: {frontend_url}\n"
|
||||||
|
"Start Vite first with:\n"
|
||||||
|
" cd frontend-vue\n"
|
||||||
|
" npm run dev"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = parse_args()
|
||||||
|
dist_server = None
|
||||||
|
|
||||||
|
if config.mode == "dist":
|
||||||
|
ensure_dist_exists()
|
||||||
|
dist_server = DistServer(config.host, config.port, config.backend_url)
|
||||||
|
dist_server.start()
|
||||||
|
app_url = dist_server.base_url
|
||||||
|
else:
|
||||||
|
ensure_dev_server_ready(config.frontend_url)
|
||||||
|
app_url = config.frontend_url
|
||||||
|
|
||||||
|
api = DesktopApi(app_url)
|
||||||
|
window = webview.create_window(
|
||||||
|
title=config.title,
|
||||||
|
url=app_url,
|
||||||
|
js_api=api,
|
||||||
|
width=config.width,
|
||||||
|
height=config.height,
|
||||||
|
min_size=(1200, 760),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
webview.start()
|
||||||
|
finally:
|
||||||
|
if dist_server is not None:
|
||||||
|
dist_server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
desktop/requirements.txt
Normal file
2
desktop/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pywebview>=5.1
|
||||||
|
requests>=2.32.0
|
||||||
67
desktop/run_desktop_dev.ps1
Normal file
67
desktop/run_desktop_dev.ps1
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
function Get-CompatiblePythonCommand {
|
||||||
|
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyLauncher) {
|
||||||
|
foreach ($version in @("3.12", "3.11")) {
|
||||||
|
try {
|
||||||
|
& py "-$version" --version *> $null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
return @("py", "-$version")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
try {
|
||||||
|
$pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
|
||||||
|
if ($pythonVersion -in @("3.12", "3.11")) {
|
||||||
|
return @("python")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Python {
|
||||||
|
param(
|
||||||
|
[string[]]$PythonCommand,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
$exe = $PythonCommand[0]
|
||||||
|
$prefix = @()
|
||||||
|
if ($PythonCommand.Length -gt 1) {
|
||||||
|
$prefix = $PythonCommand[1..($PythonCommand.Length - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
& $exe @prefix @Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonCmd = Get-CompatiblePythonCommand
|
||||||
|
if (-not $pythonCmd) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "No compatible Python version was found."
|
||||||
|
Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows."
|
||||||
|
Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet."
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Install Python 3.12 first, then run:"
|
||||||
|
Write-Host " .\desktop\run_desktop_dev.ps1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Using Python: $($pythonCmd -join ' ')"
|
||||||
|
Write-Host "Ensuring desktop dependencies..."
|
||||||
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
||||||
|
|
||||||
|
Write-Host "Starting desktop app..."
|
||||||
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000")
|
||||||
67
desktop/run_desktop_dist.ps1
Normal file
67
desktop/run_desktop_dist.ps1
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $root
|
||||||
|
|
||||||
|
function Get-CompatiblePythonCommand {
|
||||||
|
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyLauncher) {
|
||||||
|
foreach ($version in @("3.12", "3.11")) {
|
||||||
|
try {
|
||||||
|
& py "-$version" --version *> $null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
return @("py", "-$version")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
try {
|
||||||
|
$pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
|
||||||
|
if ($pythonVersion -in @("3.12", "3.11")) {
|
||||||
|
return @("python")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Python {
|
||||||
|
param(
|
||||||
|
[string[]]$PythonCommand,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
$exe = $PythonCommand[0]
|
||||||
|
$prefix = @()
|
||||||
|
if ($PythonCommand.Length -gt 1) {
|
||||||
|
$prefix = $PythonCommand[1..($PythonCommand.Length - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
& $exe @prefix @Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonCmd = Get-CompatiblePythonCommand
|
||||||
|
if (-not $pythonCmd) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "No compatible Python version was found."
|
||||||
|
Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows."
|
||||||
|
Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet."
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Install Python 3.12 first, then run:"
|
||||||
|
Write-Host " .\desktop\run_desktop_dist.ps1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Using Python: $($pythonCmd -join ' ')"
|
||||||
|
Write-Host "Ensuring desktop dependencies..."
|
||||||
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
||||||
|
|
||||||
|
Write-Host "Starting desktop app..."
|
||||||
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dist", "--backend-url", "http://127.0.0.1:8000")
|
||||||
12
frontend-vue/index.html
Normal file
12
frontend-vue/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>南日AI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2524
frontend-vue/package-lock.json
generated
Normal file
2524
frontend-vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend-vue/package.json
Normal file
26
frontend-vue/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "crawler-plugin-frontend-vue",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host --port 5173",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"element-plus": "^2.11.4",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"unplugin-auto-import": "^20.1.0",
|
||||||
|
"unplugin-vue-components": "^29.0.0",
|
||||||
|
"vite": "^7.1.3",
|
||||||
|
"vue-tsc": "^3.0.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend-vue/src/App.vue
Normal file
3
frontend-vue/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
20
frontend-vue/src/components/layout/PageShell.vue
Normal file
20
frontend-vue/src/components/layout/PageShell.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-shell" :class="themeClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
theme?: 'light' | 'dark'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const themeClass = computed(() => `theme-${props.theme}`)
|
||||||
|
</script>
|
||||||
8
frontend-vue/src/main.ts
Normal file
8
frontend-vue/src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import '@/styles/main.css'
|
||||||
|
import App from '@/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
createApp(App).use(ElementPlus).use(router).mount('#app')
|
||||||
584
frontend-vue/src/pages/admin/index.vue
Normal file
584
frontend-vue/src/pages/admin/index.vue
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
<template>
|
||||||
|
<PageShell theme="light">
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-nav">
|
||||||
|
<router-link to="/home">← 返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="page-title">管理后台</h1>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="admin-tabs" @tab-change="handleTabChange">
|
||||||
|
<el-tab-pane label="用户管理" name="users">
|
||||||
|
<div class="admin-grid">
|
||||||
|
<el-card class="form-card">
|
||||||
|
<template #header>创建用户</template>
|
||||||
|
|
||||||
|
<p class="card-tip">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="createForm.username" placeholder="用户名(至少2个字符)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="createForm.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="密码(至少6个字符)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="showRoleSelect" label="角色">
|
||||||
|
<el-select v-model="createForm.role">
|
||||||
|
<el-option label="普通号" value="normal" />
|
||||||
|
<el-option label="管理员" value="admin" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="showCreatedBySelect" label="所属管理员">
|
||||||
|
<el-select v-model="createForm.createdById" placeholder="请选择管理员">
|
||||||
|
<el-option
|
||||||
|
v-for="admin in adminsList"
|
||||||
|
:key="admin.id"
|
||||||
|
:label="admin.username"
|
||||||
|
:value="admin.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="createLoading" @click="handleCreateUser">创建用户</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="table-card">
|
||||||
|
<template #header>用户列表</template>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="searchUsername"
|
||||||
|
placeholder="输入用户名关键字"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="loadUsers(1)"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-if="isSuperAdmin"
|
||||||
|
v-model="filterCreatedBy"
|
||||||
|
placeholder="所属管理员"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="admin in adminsList"
|
||||||
|
:key="admin.id"
|
||||||
|
:label="admin.username"
|
||||||
|
:value="String(admin.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadUsers(1)">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="users" border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||||
|
<el-table-column label="角色" width="120">
|
||||||
|
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="creator_username" label="所属管理员" min-width="140">
|
||||||
|
<template #default="{ row }">{{ row.creator_username || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="removeUser(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="userPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="userTotal"
|
||||||
|
@current-change="loadUsers"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="查看生成记录" name="history">
|
||||||
|
<el-card class="table-card">
|
||||||
|
<template #header>筛选条件</template>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="filterUserId" placeholder="全部用户" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="option in userOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:label="`${option.username} (${roleLabel(option.role)})`"
|
||||||
|
:value="String(option.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filterTimeStart"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="开始时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
/>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filterTimeEnd"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="loadHistory(1)">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="table-card history-card">
|
||||||
|
<template #header>生成记录</template>
|
||||||
|
|
||||||
|
<el-table :data="historyRows" border>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="username" label="用户" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.username || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="panel_type" label="类型" min-width="140">
|
||||||
|
<template #default="{ row }">{{ row.panel_type || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||||
|
<el-table-column label="结果预览" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="getHistoryPreviewUrls(row).length" class="history-preview">
|
||||||
|
<el-image
|
||||||
|
v-for="(url, index) in getHistoryPreviewUrls(row)"
|
||||||
|
:key="`${row.id}-${index}`"
|
||||||
|
:src="url"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="getHistoryPreviewUrls(row)"
|
||||||
|
class="preview-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="historyPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="historyTotal"
|
||||||
|
@current-change="loadHistory"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-dialog v-model="editVisible" title="编辑用户" width="420px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input :model-value="editForm.username" readonly />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码(不修改留空)">
|
||||||
|
<el-input
|
||||||
|
v-model="editForm.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="留空则不修改密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="showEditRoleSelect" label="角色">
|
||||||
|
<el-select v-model="editForm.role">
|
||||||
|
<el-option label="普通号" value="normal" />
|
||||||
|
<el-option label="管理员" value="admin" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="editForm.creatorUsername" label="所属管理员">
|
||||||
|
<el-input :model-value="editForm.creatorUsername" readonly />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="editLoading" @click="saveUser">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
createAdminUser,
|
||||||
|
deleteAdminUser,
|
||||||
|
getAdminHistory,
|
||||||
|
getAdminUsers,
|
||||||
|
updateAdminUser,
|
||||||
|
type AdminHistoryItem,
|
||||||
|
type AdminUserItem,
|
||||||
|
} from '@/shared/api/admin'
|
||||||
|
|
||||||
|
interface UserOption extends AdminUserItem {}
|
||||||
|
|
||||||
|
const activeTab = ref<'users' | 'history'>('users')
|
||||||
|
const pageSize = 15
|
||||||
|
|
||||||
|
const currentUserRole = ref<'super_admin' | 'admin' | 'normal' | string>('admin')
|
||||||
|
const adminsList = ref<{ id: number; username: string }[]>([])
|
||||||
|
const users = ref<AdminUserItem[]>([])
|
||||||
|
const userTotal = ref(0)
|
||||||
|
const userPage = ref(1)
|
||||||
|
const searchUsername = ref('')
|
||||||
|
const filterCreatedBy = ref('')
|
||||||
|
const createLoading = ref(false)
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'normal',
|
||||||
|
createdById: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userOptions = ref<UserOption[]>([])
|
||||||
|
const filterUserId = ref('')
|
||||||
|
const filterTimeStart = ref('')
|
||||||
|
const filterTimeEnd = ref('')
|
||||||
|
const historyRows = ref<AdminHistoryItem[]>([])
|
||||||
|
const historyTotal = ref(0)
|
||||||
|
const historyPage = ref(1)
|
||||||
|
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editLoading = ref(false)
|
||||||
|
const editForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'normal',
|
||||||
|
creatorUsername: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSuperAdmin = computed(() => currentUserRole.value === 'super_admin')
|
||||||
|
const showRoleSelect = computed(() => isSuperAdmin.value)
|
||||||
|
const showCreatedBySelect = computed(
|
||||||
|
() => isSuperAdmin.value && createForm.role === 'normal' && adminsList.value.length > 0,
|
||||||
|
)
|
||||||
|
const showEditRoleSelect = computed(() => isSuperAdmin.value)
|
||||||
|
|
||||||
|
function roleLabel(role?: string) {
|
||||||
|
if (role === 'super_admin') return '超级管理员'
|
||||||
|
if (role === 'admin') return '管理员'
|
||||||
|
return '普通号'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsersQuery(page: number) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (searchUsername.value.trim()) {
|
||||||
|
params.set('username', searchUsername.value.trim())
|
||||||
|
}
|
||||||
|
if (filterCreatedBy.value) {
|
||||||
|
params.set('created_by_id', filterCreatedBy.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(page = 1) {
|
||||||
|
userPage.value = page
|
||||||
|
try {
|
||||||
|
const result = await getAdminUsers(buildUsersQuery(page))
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '加载用户失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserRole.value = result.current_user_role || 'admin'
|
||||||
|
adminsList.value = result.admins || []
|
||||||
|
users.value = result.items || []
|
||||||
|
userTotal.value = result.total || 0
|
||||||
|
|
||||||
|
if (!isSuperAdmin.value) {
|
||||||
|
createForm.role = 'normal'
|
||||||
|
createForm.createdById = undefined
|
||||||
|
filterCreatedBy.value = ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserOptions() {
|
||||||
|
try {
|
||||||
|
const result = await getAdminUsers('page=1&page_size=999')
|
||||||
|
if (result.success) {
|
||||||
|
userOptions.value = result.items || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
userOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryQuery(page: number) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filterUserId.value) {
|
||||||
|
params.set('user_id', filterUserId.value)
|
||||||
|
}
|
||||||
|
if (filterTimeStart.value) {
|
||||||
|
params.set('time_start', filterTimeStart.value)
|
||||||
|
}
|
||||||
|
if (filterTimeEnd.value) {
|
||||||
|
params.set('time_end', filterTimeEnd.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory(page = 1) {
|
||||||
|
historyPage.value = page
|
||||||
|
try {
|
||||||
|
const result = await getAdminHistory(buildHistoryQuery(page))
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '加载记录失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
historyRows.value = result.items || []
|
||||||
|
historyTotal.value = result.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser() {
|
||||||
|
const username = createForm.username.trim()
|
||||||
|
if (username.length < 2) {
|
||||||
|
ElMessage.warning('用户名至少2个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createForm.password.length < 6) {
|
||||||
|
ElMessage.warning('密码至少6个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreatedBySelect.value && !createForm.createdById) {
|
||||||
|
ElMessage.warning('请选择所属管理员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: string
|
||||||
|
created_by_id?: number
|
||||||
|
} = {
|
||||||
|
username,
|
||||||
|
password: createForm.password,
|
||||||
|
role: isSuperAdmin.value ? createForm.role : 'normal',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreatedBySelect.value && createForm.createdById) {
|
||||||
|
payload.created_by_id = createForm.createdById
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createAdminUser(payload)
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '创建失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success(result.msg || '创建成功')
|
||||||
|
createForm.username = ''
|
||||||
|
createForm.password = ''
|
||||||
|
createForm.role = 'normal'
|
||||||
|
createForm.createdById = undefined
|
||||||
|
await loadUsers(1)
|
||||||
|
await loadUserOptions()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(user: AdminUserItem) {
|
||||||
|
editForm.id = user.id
|
||||||
|
editForm.username = user.username || ''
|
||||||
|
editForm.password = ''
|
||||||
|
editForm.role = user.role === 'admin' ? 'admin' : 'normal'
|
||||||
|
editForm.creatorUsername = user.creator_username || ''
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
editLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: { password?: string; role?: string } = {}
|
||||||
|
if (editForm.password) {
|
||||||
|
if (editForm.password.length < 6) {
|
||||||
|
ElMessage.warning('密码至少6个字符')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.password = editForm.password
|
||||||
|
}
|
||||||
|
if (showEditRoleSelect.value) {
|
||||||
|
payload.role = editForm.role
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateAdminUser(editForm.id, payload)
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '保存失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editVisible.value = false
|
||||||
|
ElMessage.success(result.msg || '保存成功')
|
||||||
|
await loadUsers(userPage.value)
|
||||||
|
await loadUserOptions()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(user: AdminUserItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除用户 "${user.username || ''}" 吗?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteAdminUser(user.id)
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '删除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadUsers(userPage.value)
|
||||||
|
await loadUserOptions()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryPreviewUrls(row: AdminHistoryItem) {
|
||||||
|
const urls = row.long_image_url ? [row.long_image_url] : []
|
||||||
|
return urls.concat(row.result_urls || []).slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabChange(name: string | number) {
|
||||||
|
if (name === 'history') {
|
||||||
|
loadHistory(1).catch(() => undefined)
|
||||||
|
loadUserOptions().catch(() => undefined)
|
||||||
|
} else {
|
||||||
|
loadUsers(1).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadUsers(1)
|
||||||
|
await loadUserOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card,
|
||||||
|
.table-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tip {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar > * {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.admin-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1096
frontend-vue/src/pages/brand/index.vue
Normal file
1096
frontend-vue/src/pages/brand/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
171
frontend-vue/src/pages/home/index.vue
Normal file
171
frontend-vue/src/pages/home/index.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<PageShell theme="light">
|
||||||
|
<div class="home-page">
|
||||||
|
<header class="home-header">
|
||||||
|
<div class="brand">南日AI</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span>{{ username }}</span>
|
||||||
|
<el-popover placement="bottom-end" :width="320" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button circle>↻</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="update-panel">
|
||||||
|
<div class="update-title">软件更新</div>
|
||||||
|
<div class="update-version">当前版本: v{{ version }}</div>
|
||||||
|
<el-button type="primary" text :loading="loading" @click="check">检测更新</el-button>
|
||||||
|
<div class="update-hint">{{ hint }}</div>
|
||||||
|
<el-button
|
||||||
|
v-if="downloadVisible"
|
||||||
|
type="success"
|
||||||
|
:loading="downloadLoading"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<a href="/login" @click.prevent="handleLogout">退出</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="home-main">
|
||||||
|
<div class="entrances">
|
||||||
|
<router-link v-if="isVisible('brand')" class="entrance-card" to="/brand">亚马逊</router-link>
|
||||||
|
<button v-if="isVisible('wb')" class="entrance-card is-disabled" type="button" @click="showComingSoon">
|
||||||
|
wildberries
|
||||||
|
</button>
|
||||||
|
<router-link v-if="isVisible('image')" class="entrance-card" to="/image">图片</router-link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { logout } from '@/shared/api/auth'
|
||||||
|
import { useUpdateCheck } from '@/shared/composables/useUpdateCheck'
|
||||||
|
import { getBootstrapState } from '@/shared/utils/bootstrap'
|
||||||
|
import { getUserColumnPermissions } from '@/shared/api/admin'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const bootstrap = getBootstrapState()
|
||||||
|
const username = computed(() => bootstrap.username || '用户')
|
||||||
|
const userId = computed(() => bootstrap.userId || '')
|
||||||
|
const visibleKeys = ref<string[]>(['brand', 'wb', 'image'])
|
||||||
|
const { loading, version, hint, downloadVisible, downloadLoading, check, runUpdate } = useUpdateCheck()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getUserColumnPermissions(userId.value)
|
||||||
|
if (result.success && Array.isArray(result.items)) {
|
||||||
|
visibleKeys.value = result.items
|
||||||
|
.map((item) => (item.column_key || '').toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep default entrances if permissions cannot be loaded
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isVisible = (key: string) => visibleKeys.value.includes(key)
|
||||||
|
|
||||||
|
const showComingSoon = () => {
|
||||||
|
ElMessage.info('暂未开通,敬请期待')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
localStorage.removeItem('maixiang_api_key')
|
||||||
|
window.__BOOTSTRAP__ = {}
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
const confirmed = window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')
|
||||||
|
if (!confirmed) return
|
||||||
|
await runUpdate()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
||||||
|
url('/static/bg.jpg') center/cover no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-main {
|
||||||
|
min-height: calc(100vh - 72px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entrances {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entrance-card {
|
||||||
|
width: 180px;
|
||||||
|
height: 110px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-version,
|
||||||
|
.update-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
frontend-vue/src/pages/image/index.vue
Normal file
94
frontend-vue/src/pages/image/index.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<PageShell theme="dark">
|
||||||
|
<div class="image-page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<div class="logo-area">
|
||||||
|
<span class="app-name">南日AI</span>
|
||||||
|
<router-link to="/home" class="btn-home">返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<div class="nav-tab-group">
|
||||||
|
<button type="button" class="nav-tab active">图片</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-body"></main>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
document.title = '图片 - 南日AI'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: rgba(13, 13, 13, 1);
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: "Microsoft YaHei", "Noto Serif SC", "SimSun", "Songti SC", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-home:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(77, 72, 72, 0.99);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3498db;
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
frontend-vue/src/pages/login/index.vue
Normal file
148
frontend-vue/src/pages/login/index.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<PageShell theme="light">
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-header">南日AI</div>
|
||||||
|
<el-card class="login-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="login-title">登录</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
:title="errorMessage"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
class="login-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form label-width="72px" @submit.prevent="handleSubmit">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="form.username" placeholder="请输入用户名" autofocus />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入密码"
|
||||||
|
@keydown.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" class="login-button" @click="handleSubmit">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { checkAuth, login } from '@/shared/api/auth'
|
||||||
|
import { getBootstrapState } from '@/shared/utils/bootstrap'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const bootstrap = getBootstrapState()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref(bootstrap.error || '')
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const result = await checkAuth()
|
||||||
|
if (!result.logged_in) return
|
||||||
|
|
||||||
|
const target = result.redirect || '/home'
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
await router.push(target)
|
||||||
|
} else {
|
||||||
|
window.location.href = target
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore auth check failures during bootstrap
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.username || !form.password) {
|
||||||
|
errorMessage.value = '请输入用户名和密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await login(form.username, form.password)
|
||||||
|
if (result.success) {
|
||||||
|
const target = result.redirect || '/home'
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
await router.push(target)
|
||||||
|
} else {
|
||||||
|
window.location.href = target
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = result.error || '登录失败'
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '登录失败'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
background: linear-gradient(180deg, #dce8f1 0%, #eef4f8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
color: #303133;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
frontend-vue/src/router.ts
Normal file
46
frontend-vue/src/router.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('@/pages/login/index.vue'),
|
||||||
|
meta: { title: '登录 - 南日AI' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home',
|
||||||
|
component: () => import('@/pages/home/index.vue'),
|
||||||
|
meta: { title: '首页 - 南日AI' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/brand',
|
||||||
|
component: () => import('@/pages/brand/index.vue'),
|
||||||
|
meta: { title: '亚马逊 - 南日AI' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
component: () => import('@/pages/admin/index.vue'),
|
||||||
|
meta: { title: '管理后台 - 南日AI' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/image',
|
||||||
|
component: () => import('@/pages/image/index.vue'),
|
||||||
|
meta: { title: '图片 - 南日AI' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = String(to.meta.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
96
frontend-vue/src/shared/api/admin.ts
Normal file
96
frontend-vue/src/shared/api/admin.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { requestDeleteJson, requestGetJson, requestPostJson, requestPutJson } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface AdminUserItem {
|
||||||
|
id: number
|
||||||
|
username?: string
|
||||||
|
role?: 'super_admin' | 'admin' | 'normal' | string
|
||||||
|
creator_username?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSimpleUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUsersResponse {
|
||||||
|
success: boolean
|
||||||
|
current_user_role?: 'super_admin' | 'admin' | 'normal' | string
|
||||||
|
admins?: AdminSimpleUser[]
|
||||||
|
items?: AdminUserItem[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMutationResponse {
|
||||||
|
success: boolean
|
||||||
|
msg?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminHistoryItem {
|
||||||
|
id: number
|
||||||
|
username?: string
|
||||||
|
panel_type?: string
|
||||||
|
created_at?: string
|
||||||
|
long_image_url?: string | null
|
||||||
|
result_urls?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminHistoryResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: AdminHistoryItem[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminColumnPermissionItem {
|
||||||
|
column_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminColumnPermissionResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: AdminColumnPermissionItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminUsers(query = '') {
|
||||||
|
const suffix = query ? `?${query}` : ''
|
||||||
|
return requestGetJson<AdminUsersResponse>(`/api/admin/users${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminUser(payload: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: string
|
||||||
|
created_by_id?: number
|
||||||
|
}) {
|
||||||
|
return requestPostJson<AdminMutationResponse>('/api/admin/user', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdminUser(
|
||||||
|
userId: number | string,
|
||||||
|
payload: {
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return requestPutJson<AdminMutationResponse>(`/api/admin/user/${userId}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAdminUser(userId: number | string) {
|
||||||
|
return requestDeleteJson<AdminMutationResponse>(`/api/admin/user/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminHistory(query = '') {
|
||||||
|
const suffix = query ? `?${query}` : ''
|
||||||
|
return requestGetJson<AdminHistoryResponse>(`/api/admin/history${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserColumnPermissions(userId: string | number) {
|
||||||
|
return requestGetJson<AdminColumnPermissionResponse>(`/api/admin/user/${userId}/column-permissions`)
|
||||||
|
}
|
||||||
38
frontend-vue/src/shared/api/auth.ts
Normal file
38
frontend-vue/src/shared/api/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { requestGetJson, requestPostJson } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
success: boolean
|
||||||
|
redirect?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthCheckResponse {
|
||||||
|
logged_in: boolean
|
||||||
|
redirect?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAuth() {
|
||||||
|
return requestGetJson<AuthCheckResponse>('/api/auth/check')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('username', username)
|
||||||
|
formData.set('password', password)
|
||||||
|
|
||||||
|
return requestPostJson<LoginResponse>('/login', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await requestGetJson('/logout', {
|
||||||
|
validateStatus: () => true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// logout should still continue on the client even if backend responds abnormally
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend-vue/src/shared/api/brand.ts
Normal file
88
frontend-vue/src/shared/api/brand.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { requestDeleteJson, requestGetJson, requestPostJson } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface BrandExpandFolderResponse {
|
||||||
|
success: boolean
|
||||||
|
paths?: string[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandTaskResultPaths {
|
||||||
|
zip_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandTaskItem {
|
||||||
|
id: number | string
|
||||||
|
status?: 'pending' | 'running' | 'success' | 'failed' | 'cancelled' | string
|
||||||
|
desc?: string
|
||||||
|
file_paths?: string[]
|
||||||
|
created_at?: string
|
||||||
|
progress_total?: number
|
||||||
|
progress_current?: number
|
||||||
|
result_paths?: BrandTaskResultPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandTaskListResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: BrandTaskItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandTaskDetailResponse {
|
||||||
|
success: boolean
|
||||||
|
task?: BrandTaskItem
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandTaskMutationResponse {
|
||||||
|
success: boolean
|
||||||
|
task_id?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTaskEventsUrl(taskId: string | number) {
|
||||||
|
return `/api/brand/tasks/${taskId}/events`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTaskDownloadUrl(taskId: string | number) {
|
||||||
|
return `/api/brand/download/${taskId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTemplateXlsxUrl() {
|
||||||
|
return '/static/品牌文档格式_模板.xlsx'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTemplateZipUrl() {
|
||||||
|
return '/static/模板2-以文件夹方式上传.zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandBrandFolder(folder: string) {
|
||||||
|
return requestPostJson<BrandExpandFolderResponse>('/api/brand/expand-folder', { folder })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runBrandNow(paths: string[], strategy: string) {
|
||||||
|
return requestPostJson<BrandTaskMutationResponse>('/api/brand/run', { paths, strategy })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrandTask(paths: string[], strategy: string) {
|
||||||
|
return requestPostJson<BrandTaskMutationResponse>('/api/brand/tasks', { paths, strategy })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTasks() {
|
||||||
|
return requestGetJson<BrandTaskListResponse>('/api/brand/tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrandTask(taskId: string | number) {
|
||||||
|
return requestGetJson<BrandTaskDetailResponse>(`/api/brand/tasks/${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBrandTask(taskId: string | number) {
|
||||||
|
return requestPostJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBrandTask(taskId: string | number) {
|
||||||
|
return requestDeleteJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrandTaskEvents(taskId: string | number) {
|
||||||
|
return new EventSource(getBrandTaskEventsUrl(taskId))
|
||||||
|
}
|
||||||
107
frontend-vue/src/shared/api/http.ts
Normal file
107
frontend-vue/src/shared/api/http.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
type AxiosInstance,
|
||||||
|
type AxiosRequestConfig,
|
||||||
|
type AxiosResponse,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from 'axios'
|
||||||
|
|
||||||
|
export interface ApiSuccess<T> {
|
||||||
|
success: true
|
||||||
|
msg?: string
|
||||||
|
data?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiFailure {
|
||||||
|
success: false
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure
|
||||||
|
export type RequestOptions<D = unknown> = AxiosRequestConfig<D>
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown) {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
const data = error.response?.data
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'error' in data) {
|
||||||
|
return String(data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string' && data.trim()) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message || '请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : '请求失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpClient(): AxiosInstance {
|
||||||
|
const instance = axios.create({
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
(error: unknown) => Promise.reject(new Error(extractErrorMessage(error))),
|
||||||
|
)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = createHttpClient()
|
||||||
|
|
||||||
|
export async function request<T = unknown, D = unknown>(config: RequestOptions<D>): Promise<T> {
|
||||||
|
const response = await http.request<T, AxiosResponse<T>, D>(config)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get<T = unknown>(url: string, config?: RequestOptions) {
|
||||||
|
return request<T>({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function post<T = unknown, D = unknown>(url: string, data?: D, config?: RequestOptions<D>) {
|
||||||
|
return request<T, D>({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function put<T = unknown, D = unknown>(url: string, data?: D, config?: RequestOptions<D>) {
|
||||||
|
return request<T, D>({
|
||||||
|
url,
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function del<T = unknown>(url: string, config?: RequestOptions) {
|
||||||
|
return request<T>({
|
||||||
|
url,
|
||||||
|
method: 'DELETE',
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestJson = request
|
||||||
|
export const requestGetJson = get
|
||||||
|
export const requestPostJson = post
|
||||||
|
export const requestPutJson = put
|
||||||
|
export const requestDeleteJson = del
|
||||||
22
frontend-vue/src/shared/api/update.ts
Normal file
22
frontend-vue/src/shared/api/update.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { requestGetJson, requestPostJson } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export interface VersionResponse {
|
||||||
|
version?: string
|
||||||
|
has_update?: boolean
|
||||||
|
latest_version?: string
|
||||||
|
desc?: string
|
||||||
|
file_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStartResponse {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchVersion() {
|
||||||
|
return requestGetJson<VersionResponse>('/api/version')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startUpdate(fileUrl: string) {
|
||||||
|
return requestPostJson<UpdateStartResponse>('/api/update/do', { file_url: fileUrl })
|
||||||
|
}
|
||||||
24
frontend-vue/src/shared/bridges/pywebview.ts
Normal file
24
frontend-vue/src/shared/bridges/pywebview.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface PywebviewApi {
|
||||||
|
select_brand_xlsx_files?: () => Promise<string[]>
|
||||||
|
select_brand_folder?: () => Promise<string | null>
|
||||||
|
select_folder?: () => Promise<string | null>
|
||||||
|
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
|
save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
|
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
pywebview?: {
|
||||||
|
api?: PywebviewApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPywebviewApi() {
|
||||||
|
return window.pywebview?.api
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPywebview() {
|
||||||
|
return Boolean(getPywebviewApi())
|
||||||
|
}
|
||||||
71
frontend-vue/src/shared/composables/useUpdateCheck.ts
Normal file
71
frontend-vue/src/shared/composables/useUpdateCheck.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchVersion, startUpdate } from '@/shared/api/update'
|
||||||
|
|
||||||
|
export function useUpdateCheck() {
|
||||||
|
const loading = ref(false)
|
||||||
|
const version = ref('1.0.0')
|
||||||
|
const hint = ref('')
|
||||||
|
const downloadVisible = ref(false)
|
||||||
|
const downloadLoading = ref(false)
|
||||||
|
const fileUrl = ref('')
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
loading.value = true
|
||||||
|
hint.value = '正在检测更新...'
|
||||||
|
downloadVisible.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchVersion()
|
||||||
|
version.value = (result.version || version.value).replace(/^v/i, '')
|
||||||
|
|
||||||
|
if (result.has_update && result.latest_version) {
|
||||||
|
hint.value = `发现新版本 v${result.latest_version}${result.desc ? `:${result.desc}` : ''}`
|
||||||
|
fileUrl.value = result.file_url || ''
|
||||||
|
downloadVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hint.value = '已是最新版本'
|
||||||
|
} catch (error) {
|
||||||
|
hint.value = error instanceof Error ? error.message : '检测更新失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runUpdate = async () => {
|
||||||
|
if (!fileUrl.value) {
|
||||||
|
hint.value = '暂无下载地址,请关注官方渠道。'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLoading.value = true
|
||||||
|
hint.value = '正在下载并准备更新,程序将自动退出...'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await startUpdate(fileUrl.value)
|
||||||
|
if (result.success) {
|
||||||
|
hint.value = '更新已启动,程序即将退出...'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
hint.value = result.error || '更新启动失败'
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
hint.value = error instanceof Error ? error.message : '请求更新失败,请重试'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
downloadLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
version,
|
||||||
|
hint,
|
||||||
|
downloadVisible,
|
||||||
|
downloadLoading,
|
||||||
|
check,
|
||||||
|
runUpdate,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend-vue/src/shared/utils/bootstrap.ts
Normal file
16
frontend-vue/src/shared/utils/bootstrap.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface BootstrapState {
|
||||||
|
username?: string
|
||||||
|
userId?: string
|
||||||
|
isAdmin?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__BOOTSTRAP__?: BootstrapState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBootstrapState(): BootstrapState {
|
||||||
|
return window.__BOOTSTRAP__ ?? {}
|
||||||
|
}
|
||||||
51
frontend-vue/src/styles/main.css
Normal file
51
frontend-vue/src/styles/main.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
@import './reset.css';
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-color: rgba(123, 135, 148, 0.7) rgba(255, 255, 255, 0.04);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(123, 135, 148, 0.7);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.9);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark {
|
||||||
|
background: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
}
|
||||||
27
frontend-vue/src/styles/reset.css
Normal file
27
frontend-vue/src/styles/reset.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
17
frontend-vue/src/styles/tokens.css
Normal file
17
frontend-vue/src/styles/tokens.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
:root {
|
||||||
|
--font-family-base: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||||
|
--color-primary: #409eff;
|
||||||
|
--color-bg-light: #eef3f8;
|
||||||
|
--color-bg-dark: #0d0d0d;
|
||||||
|
--color-surface-light: rgba(255, 255, 255, 0.92);
|
||||||
|
--color-surface-dark: #1a1a1a;
|
||||||
|
--color-border-light: #d6e4ef;
|
||||||
|
--color-border-dark: #2a2a2a;
|
||||||
|
--color-text-primary: #303133;
|
||||||
|
--color-text-secondary: #606266;
|
||||||
|
--color-text-dark: #e5eaf3;
|
||||||
|
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
}
|
||||||
1
frontend-vue/src/vite-env.d.ts
vendored
Normal file
1
frontend-vue/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
29
frontend-vue/tsconfig.json
Normal file
29
frontend-vue/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"auto-imports.d.ts",
|
||||||
|
"components.d.ts"
|
||||||
|
],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend-vue/tsconfig.node.json
Normal file
10
frontend-vue/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
51
frontend-vue/vite.config.ts
Normal file
51
frontend-vue/vite.config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
imports: ['vue', 'vue-router'],
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'auto-imports.d.ts',
|
||||||
|
vueTemplate: true,
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'components.d.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/login': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/logout': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user