重构项目,方便开发

This commit is contained in:
super
2026-03-19 13:15:47 +08:00
parent fb2e5ccea5
commit 1e63ab291b
38 changed files with 5940 additions and 0 deletions

15
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

334
desktop/app.py Normal file
View 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
View File

@@ -0,0 +1,2 @@
pywebview>=5.1
requests>=2.32.0

View 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")

View 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
View 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

File diff suppressed because it is too large Load Diff

26
frontend-vue/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View 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
View 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')

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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

View 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`)
}

View 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
}
}

View 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))
}

View 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

View 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 })
}

View 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())
}

View 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,
}
}

View 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__ ?? {}
}

View 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);
}

View 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;
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View 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',
},
})