feat: build licensed desktop image processor

This commit is contained in:
2026-08-26 07:26:35 +08:00
parent 6ff3d71921
commit 1f31424015
32 changed files with 6515 additions and 2 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
dist-electron/
release/
dogfood-output/
.vite/
*.log
.DS_Store
.env.local
.env.*.local
+48 -2
View File
@@ -1,3 +1,49 @@
# image-processing # 正版情趣王
图片处理 面向图片交付场景的本地批量处理桌面软件,基于 Electron、Vue 3 和 Sharp 开发。图片只在电脑本地处理,不上传到服务器。
## 核心能力
- 支持 JPG、JPEG、JPE、JFIF、PNG、WebP、GIF、TIF、TIFF、AVIF、SVG,支持选择文件夹和拖拽导入;动图和多页图片取首帧。
- 非 JPEG 输入会先转换为高质量 JPEG,透明区域使用白色背景,再执行现有混淆处理。
- 内置 AI 混淆算法,自动完成图片数据特征混淆与交付兼容处理。
- 追加数据默认 `1 MB`,可在软件中设置为 `0–100 MB`,设置会保存在本机。
- 支持生成 `原文件名.file` 和 `原文件名.file.jpg` 两种交付格式。
- 支持激活码激活、设备绑定、在线授权校验、72 小时离线宽限和软件版本升级检查。
- 生产接口基地址:`https://apimall.v2.jxxyk.com/api`。
## 本地开发
```bash
pnpm install
pnpm dev
```
## 验证与打包
```bash
pnpm test
pnpm run typecheck
pnpm run build
pnpm run dist:win
```
Windows 安装包输出到 `release/`,安装程序名称为 `正版情趣王-Setup-<版本号>.exe`。
## 后台配套
后台项目:`/Users/bing/work/code/jxxyk/jxxyk-v2-mall`
上线前先执行数据库脚本:
```text
sql/update/20260826_desktop_image_tool_license.sql
```
客户端接口:
- `POST /api/desktop/activate`:激活并绑定设备。
- `POST /api/desktop/login`:软件启动/授权校验并记录登录。
- `POST /api/desktop/version/check`:检查 `正版情趣王` 最新版本。
后台管理入口为 `/admin/desktop-license/list`,支持批量生成激活码、启停授权、查看绑定设备及登录记录。软件版本沿用 `/admin/version/list` 管理。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="112" fill="#1769E0"/>
<path d="M256 83L388 132V242C388 326 335 399 256 429C177 399 124 326 124 242V132L256 83Z" fill="white" fill-opacity="0.14"/>
<rect x="166" y="151" width="180" height="145" rx="24" fill="white"/>
<circle cx="217" cy="201" r="17" fill="#62D5C7"/>
<path d="M186 267L230 223L264 254L296 216L328 267H186Z" fill="#1769E0"/>
<rect x="196" y="322" width="120" height="18" rx="9" fill="white"/>
<rect x="219" y="357" width="74" height="18" rx="9" fill="white" fill-opacity="0.72"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

View File
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""从“郑州情趣王一手科技.exe”提取的 JPEG 图片处理逻辑。
处理步骤与原程序一致:
1. 裁掉 JPEG 底部两行并以 quality=95 重新编码;
2. 把 SOF 段中的图片高度改回原始高度;
3. 删除最后一个 JPEG EOI(FF D9)结束标志;
4. 按指定大小追加随机字节,输出为同名 .file 文件。
注意:输出是刻意构造的非标准 JPEG,部分看图软件可能拒绝打开。
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
from typing import Iterable
try:
from PIL import Image
except ImportError as exc:
raise SystemExit("缺少 Pillow,请先执行:python3 -m pip install pillow") from exc
JPEG_EXTENSIONS = {".jpg", ".jpeg", ".jpe", ".jfif"}
SOF_MARKERS = {
0xC0,
0xC1,
0xC2,
0xC3,
0xC5,
0xC6,
0xC7,
0xC9,
0xCA,
0xCB,
0xCD,
0xCE,
0xCF,
}
def find_and_modify_height(data: bytearray, original_height: int) -> bytearray | None:
"""找到第一个 JPEG SOF 段,把高度字段恢复为裁剪前的高度。"""
index = 0
length = len(data)
while index < length - 1:
if data[index] != 0xFF:
index += 1
continue
marker = data[index + 1]
if marker in SOF_MARKERS:
height_offset = index + 5
if height_offset + 1 >= length:
return None
data[height_offset] = (original_height >> 8) & 0xFF
data[height_offset + 1] = original_height & 0xFF
return data
# SOI 和 EOI 标记没有长度字段。
if marker in (0xD8, 0xD9):
index += 2
continue
if index + 3 < length:
segment_length = (data[index + 2] << 8) | data[index + 3]
index += 2 + segment_length
else:
index += 2
return None
def remove_eoi(data: bytearray) -> tuple[bytearray, bool]:
"""从末尾向前查找并删除最后一个 FF D9 结束标志。"""
for index in range(len(data) - 1, 0, -1):
if data[index - 1] == 0xFF and data[index] == 0xD9:
return data[: index - 1] + data[index + 1 :], True
return data, False
def process_single_image(
input_file: Path | str,
output_file: Path | str,
junk_size_mb: float = 1.0,
) -> Path:
"""按原程序逻辑处理一张图片,并返回输出路径。"""
input_path = Path(input_file)
output_path = Path(output_file)
temp_path = Path(f"{output_path}.tmp")
if not 0 <= junk_size_mb <= 100:
raise ValueError("junk_size_mb 必须在 0 到 100 之间")
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
with Image.open(input_path) as image:
width, height = image.size
if height <= 2:
raise ValueError(f"图片高度不足 3 行:{input_path}")
cropped_image = image.crop((0, 0, width, height - 2))
try:
cropped_image.save(temp_path, "JPEG", quality=95)
finally:
cropped_image.close()
image_data = bytearray(temp_path.read_bytes())
modified_data = find_and_modify_height(image_data, height) or image_data
modified_data, _ = remove_eoi(modified_data)
junk_size_bytes = int(junk_size_mb * 1024 * 1024)
if junk_size_bytes > 0:
# 保留原程序的逐字节伪随机生成方式。
modified_data += bytearray(
random.getrandbits(8) for _ in range(junk_size_bytes)
)
output_path.write_bytes(modified_data)
return output_path
finally:
if temp_path.exists():
temp_path.unlink()
def collect_images(paths: Iterable[Path]) -> list[Path]:
"""收集输入文件,并按原程序支持的扩展名递归扫描目录。"""
images: list[Path] = []
for path in paths:
if path.is_file() and path.suffix.lower() in JPEG_EXTENSIONS:
images.append(path)
elif path.is_dir():
images.extend(
candidate
for candidate in path.rglob("*")
if candidate.is_file()
and candidate.suffix.lower() in JPEG_EXTENSIONS
)
else:
print(f"跳过无效路径或非 JPEG 文件:{path}", file=sys.stderr)
return images
def build_output_path(input_path: Path, output_dir: Path | None) -> Path:
"""原程序使用图片主文件名加 .file 作为输出名。"""
directory = output_dir if output_dir is not None else input_path.parent
return directory / f"{input_path.stem}.file"
def main() -> int:
parser = argparse.ArgumentParser(
description="裁掉 JPEG 底部两行、伪造原高度、删除 EOI 并追加随机数据。"
)
parser.add_argument("inputs", nargs="+", type=Path, help="JPEG 文件或目录")
parser.add_argument("-o", "--output-dir", type=Path, help="统一输出目录")
parser.add_argument(
"--junk-mb",
type=float,
default=1.0,
help="每张图片追加的随机数据大小,范围 0–100 MB,默认 1 MB",
)
args = parser.parse_args()
if not 0 <= args.junk_mb <= 100:
parser.error("--junk-mb 必须在 0 到 100 之间")
images = collect_images(args.inputs)
if not images:
print("没有找到可处理的 JPEG 图片。", file=sys.stderr)
return 1
succeeded = 0
for index, input_path in enumerate(images, start=1):
output_path = build_output_path(input_path, args.output_dir)
try:
process_single_image(input_path, output_path, args.junk_mb)
succeeded += 1
print(f"[{index}/{len(images)}] 完成:{input_path} -> {output_path}")
except Exception as exc:
print(f"[{index}/{len(images)}] 失败:{input_path} - {exc}", file=sys.stderr)
print(f"处理完成:总计 {len(images)},成功 {succeeded},失败 {len(images) - succeeded}")
return 0 if succeeded == len(images) else 2
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.
+106
View File
@@ -0,0 +1,106 @@
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import sharp from 'sharp'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ProcessOptions } from '../../src/shared/types'
import {
ImageProcessor,
processSingleImage,
readSofHeight,
} from './image-processor'
describe('image processing algorithm', () => {
let temporaryDirectory: string
let inputPath: string
beforeEach(async () => {
temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'genuine-qingquwang-'))
inputPath = path.join(temporaryDirectory, 'sample.jpg')
await sharp({
create: {
width: 18,
height: 12,
channels: 3,
background: { r: 23, g: 105, b: 224 },
},
}).jpeg({ quality: 90 }).toFile(inputPath)
})
afterEach(async () => {
await fs.rm(temporaryDirectory, { recursive: true, force: true })
})
it('writes a .file without EOI while restoring original SOF height', async () => {
const outputPath = await processSingleImage(inputPath, options('file', 0))
const output = await fs.readFile(outputPath)
expect(outputPath).toBe(path.join(temporaryDirectory, 'sample.file'))
expect(readSofHeight(output)).toBe(12)
expect(output.subarray(-2).equals(Buffer.from([0xff, 0xd9]))).toBe(false)
})
it('supports .file.jpg and appends the configured random byte count', async () => {
const baselinePath = await processSingleImage(inputPath, options('file', 0))
const baselineSize = (await fs.stat(baselinePath)).size
const outputPath = await processSingleImage(inputPath, options('file-jpg', 0.01))
const outputSize = (await fs.stat(outputPath)).size
expect(outputPath).toBe(path.join(temporaryDirectory, 'sample.file.jpg'))
expect(outputSize - baselineSize).toBe(Math.floor(0.01 * 1024 * 1024))
})
it('discovers and converts supported non-JPEG inputs into JPEG delivery files', async () => {
const source = sharp({
create: {
width: 18,
height: 12,
channels: 4,
background: { r: 23, g: 105, b: 224, alpha: 0.5 },
},
})
const inputs = [
path.join(temporaryDirectory, 'sample.png'),
path.join(temporaryDirectory, 'sample.webp'),
path.join(temporaryDirectory, 'sample.gif'),
path.join(temporaryDirectory, 'sample.tiff'),
path.join(temporaryDirectory, 'sample.avif'),
]
await Promise.all([
source.clone().png().toFile(inputs[0]),
source.clone().webp().toFile(inputs[1]),
source.clone().gif().toFile(inputs[2]),
source.clone().tiff().toFile(inputs[3]),
source.clone().avif().toFile(inputs[4]),
fs.writeFile(path.join(temporaryDirectory, 'sample.svg'), '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="12"><rect width="18" height="12" fill="#1769e0"/></svg>'),
fs.writeFile(path.join(temporaryDirectory, 'ignored.bmp'), 'not-supported'),
])
const processor = new ImageProcessor()
const inspected = await processor.inspect([temporaryDirectory])
const inspectedNames = inspected.map((item) => item.name)
expect(inspectedNames).toEqual([
'sample.avif',
'sample.gif',
'sample.jpg',
'sample.png',
'sample.svg',
'sample.tiff',
'sample.webp',
])
for (const input of [...inputs, path.join(temporaryDirectory, 'sample.svg')]) {
const outputPath = await processSingleImage(input, options('file', 0))
expect(readSofHeight(await fs.readFile(outputPath))).toBe(12)
}
})
function options(outputFormat: 'file' | 'file-jpg', junkSizeMb: number): ProcessOptions {
return {
paths: [inputPath],
useSourceDirectory: true,
outputFormat,
junkSizeMb,
}
}
})
+294
View File
@@ -0,0 +1,294 @@
import { createHash, randomBytes } from 'node:crypto'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import sharp from 'sharp'
import type {
ImageFileItem,
ProcessOptions,
ProcessProgress,
ProcessSummary,
} from '../../src/shared/types'
export const IMAGE_INPUT_EXTENSIONS = [
'jpg', 'jpeg', 'jpe', 'jfif',
'png', 'webp', 'gif', 'tif', 'tiff', 'avif', 'svg',
]
const IMAGE_EXTENSIONS = new Set(IMAGE_INPUT_EXTENSIONS.map((extension) => `.${extension}`))
const SOF_MARKERS = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
])
export class ImageProcessor {
private cancelled = false
cancel(): void {
this.cancelled = true
}
async inspect(inputPaths: string[]): Promise<ImageFileItem[]> {
const files = await collectImagePaths(inputPaths)
return Promise.all(files.map(async (filePath) => {
const stat = await fs.stat(filePath)
try {
const metadata = await sharp(filePath).metadata()
return {
id: fileId(filePath),
path: filePath,
name: path.basename(filePath),
size: stat.size,
width: metadata.width ?? null,
height: metadata.height ?? null,
status: 'pending' as const,
}
} catch (error) {
return {
id: fileId(filePath),
path: filePath,
name: path.basename(filePath),
size: stat.size,
width: null,
height: null,
status: 'failed' as const,
error: error instanceof Error ? error.message : '无法读取图片',
}
}
}))
}
async process(
options: ProcessOptions,
onProgress: (progress: ProcessProgress) => void,
): Promise<ProcessSummary> {
validateOptions(options)
this.cancelled = false
let completed = 0
let failed = 0
for (let index = 0; index < options.paths.length; index++) {
const inputPath = options.paths[index]
if (this.cancelled) {
break
}
const item = await inspectSingle(inputPath)
item.status = 'processing'
onProgress(progressFor(index, options.paths.length, item, false))
try {
item.outputPath = await processSingleImage(inputPath, options, () => this.cancelled)
item.status = this.cancelled ? 'cancelled' : 'completed'
if (item.status === 'completed') {
completed += 1
}
} catch (error) {
item.status = this.cancelled ? 'cancelled' : 'failed'
item.error = error instanceof Error ? error.message : '处理失败'
if (!this.cancelled) {
failed += 1
}
}
onProgress(progressFor(index + 1, options.paths.length, item, this.cancelled))
}
return {
total: options.paths.length,
completed,
failed,
cancelled: this.cancelled,
}
}
}
export async function processSingleImage(
inputPath: string,
options: ProcessOptions,
isCancelled: () => boolean = () => false,
): Promise<string> {
const metadata = await sharp(inputPath, { page: 0, pages: 1 }).metadata()
if (!metadata.width || !metadata.height || metadata.height <= 2) {
throw new Error('图片尺寸过小,无法执行 AI 混淆处理')
}
const encoded = await sharp(inputPath, { page: 0, pages: 1 })
.flatten({ background: '#ffffff' })
.extract({ left: 0, top: 0, width: metadata.width, height: metadata.height - 2 })
.jpeg({ quality: 95 })
.toBuffer()
const heightRestored = restoreSofHeight(encoded, metadata.height)
const withoutEoi = removeLastEoi(heightRestored)
const outputPath = buildOutputPath(inputPath, options)
const temporaryPath = `${outputPath}.${process.pid}.tmp`
await fs.mkdir(path.dirname(outputPath), { recursive: true })
try {
await fs.writeFile(temporaryPath, withoutEoi)
let remaining = Math.floor(options.junkSizeMb * 1024 * 1024)
if (remaining > 0) {
const handle = await fs.open(temporaryPath, 'a')
try {
while (remaining > 0) {
if (isCancelled()) {
throw new Error('处理已取消')
}
const chunkSize = Math.min(remaining, 1024 * 1024)
await handle.write(randomBytes(chunkSize))
remaining -= chunkSize
}
} finally {
await handle.close()
}
}
await fs.rm(outputPath, { force: true })
await fs.rename(temporaryPath, outputPath)
return outputPath
} finally {
await fs.rm(temporaryPath, { force: true })
}
}
export function restoreSofHeight(buffer: Buffer, originalHeight: number): Buffer {
const result = Buffer.from(buffer)
let index = 0
while (index < result.length - 1) {
if (result[index] !== 0xff) {
index += 1
continue
}
let markerIndex = index + 1
while (markerIndex < result.length && result[markerIndex] === 0xff) {
markerIndex += 1
}
const marker = result[markerIndex]
if (SOF_MARKERS.has(marker)) {
const heightOffset = markerIndex + 4
if (heightOffset + 1 >= result.length) {
throw new Error('JPEG SOF 高度字段不完整')
}
result[heightOffset] = (originalHeight >> 8) & 0xff
result[heightOffset + 1] = originalHeight & 0xff
return result
}
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
index = markerIndex + 1
continue
}
if (markerIndex + 2 >= result.length) {
break
}
const segmentLength = result.readUInt16BE(markerIndex + 1)
if (segmentLength < 2) {
break
}
index = markerIndex + 1 + segmentLength
}
throw new Error('未找到 JPEG SOF 图片尺寸段')
}
export function removeLastEoi(buffer: Buffer): Buffer {
for (let index = buffer.length - 1; index > 0; index--) {
if (buffer[index - 1] === 0xff && buffer[index] === 0xd9) {
return Buffer.concat([buffer.subarray(0, index - 1), buffer.subarray(index + 1)])
}
}
return buffer
}
export function readSofHeight(buffer: Buffer): number | null {
let index = 0
while (index < buffer.length - 7) {
if (buffer[index] === 0xff && SOF_MARKERS.has(buffer[index + 1])) {
return buffer.readUInt16BE(index + 5)
}
index += 1
}
return null
}
async function collectImagePaths(inputPaths: string[]): Promise<string[]> {
const result: string[] = []
const seen = new Set<string>()
const stack = [...inputPaths]
while (stack.length > 0) {
const candidate = stack.pop()
if (!candidate) continue
let stat
try {
stat = await fs.lstat(candidate)
} catch {
continue
}
if (stat.isSymbolicLink()) continue
if (stat.isDirectory()) {
const entries = await fs.readdir(candidate, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isSymbolicLink()) {
stack.push(path.join(candidate, entry.name))
}
}
} else if (stat.isFile() && IMAGE_EXTENSIONS.has(path.extname(candidate).toLowerCase())) {
const absolutePath = path.resolve(candidate)
if (!seen.has(absolutePath)) {
seen.add(absolutePath)
result.push(absolutePath)
}
}
}
return result.sort((left, right) => left.localeCompare(right, 'zh-CN'))
}
async function inspectSingle(filePath: string): Promise<ImageFileItem> {
const [stat, metadata] = await Promise.all([
fs.stat(filePath),
sharp(filePath).metadata(),
])
return {
id: fileId(filePath),
path: filePath,
name: path.basename(filePath),
size: stat.size,
width: metadata.width ?? null,
height: metadata.height ?? null,
status: 'pending',
}
}
function buildOutputPath(inputPath: string, options: ProcessOptions): string {
const outputDirectory = options.useSourceDirectory
? path.dirname(inputPath)
: path.resolve(options.outputDirectory || '')
const basename = path.basename(inputPath, path.extname(inputPath))
const suffix = options.outputFormat === 'file-jpg' ? '.file.jpg' : '.file'
return path.join(outputDirectory, `${basename}${suffix}`)
}
function validateOptions(options: ProcessOptions): void {
if (!options.paths.length) {
throw new Error('请先添加需要处理的图片')
}
if (!options.useSourceDirectory && !options.outputDirectory) {
throw new Error('请选择输出目录')
}
if (!Number.isFinite(options.junkSizeMb)
|| options.junkSizeMb < 0 || options.junkSizeMb > 100) {
throw new Error('追加数据大小必须在 0 到 100 MB 之间')
}
}
function fileId(filePath: string): string {
return createHash('sha1').update(filePath).digest('hex')
}
function progressFor(
current: number,
total: number,
item: ImageFileItem,
cancelled: boolean,
): ProcessProgress {
return {
current,
total,
percent: total === 0 ? 0 : Math.round((current / total) * 100),
item,
cancelled,
}
}
+333
View File
@@ -0,0 +1,333 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
session,
shell,
} from 'electron'
import type {
DownloadItem,
Event as ElectronEvent,
IpcMainInvokeEvent,
WebContents,
} from 'electron'
import { existsSync } from 'node:fs'
import path from 'node:path'
import type { ProcessOptions } from '../../src/shared/types'
import { IMAGE_INPUT_EXTENSIONS, ImageProcessor } from './image-processor'
import { LicenseManager } from './license-manager'
app.setName('正版情趣王')
app.enableSandbox()
const singleInstance = app.requestSingleInstanceLock()
if (!singleInstance) {
app.quit()
}
let mainWindow: BrowserWindow | null = null
const imageProcessor = new ImageProcessor()
const licenseManager = new LicenseManager()
const UPDATE_INSTALLER_EXTENSIONS = new Set(['.exe', '.msi'])
function createWindow(): void {
const preload = path.join(__dirname, '../preload/index.js')
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 1360,
height: 860,
minWidth: 1120,
minHeight: 720,
show: false,
backgroundColor: '#F4F7FB',
title: '正版情趣王',
titleBarStyle: 'hidden',
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
}
if (process.platform !== 'darwin') {
windowOptions.titleBarOverlay = {
color: '#F7F9FC',
symbolColor: '#596273',
height: 48,
}
}
mainWindow = new BrowserWindow(windowOptions)
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
if (targetUrl !== mainWindow?.webContents.getURL()) {
event.preventDefault()
}
})
mainWindow.once('ready-to-show', () => mainWindow?.show())
const devServerUrl = process.env.VITE_DEV_SERVER_URL
if (devServerUrl) {
void mainWindow.loadURL(devServerUrl)
} else {
void mainWindow.loadFile(path.join(__dirname, '../../../dist/index.html'))
}
}
function registerIpcHandlers(): void {
ipcMain.handle('app:info', (event) => {
assertTrustedSender(event)
return licenseManager.getAppInfo()
})
ipcMain.handle('license:state', (event) => {
assertTrustedSender(event)
return licenseManager.getState()
})
ipcMain.handle('license:activate', (event, activationCode: unknown) => {
assertTrustedSender(event)
if (typeof activationCode !== 'string' || activationCode.length > 64) {
throw new Error('激活码格式不正确')
}
return licenseManager.activate(activationCode)
})
ipcMain.handle('license:validate', (event) => {
assertTrustedSender(event)
return licenseManager.validate()
})
ipcMain.handle('license:clear', (event) => {
assertTrustedSender(event)
return licenseManager.clear()
})
ipcMain.handle('license:check-version', (event) => {
assertTrustedSender(event)
return licenseManager.checkVersion()
})
ipcMain.handle('license:download-update', async (
event,
downloadUrl: unknown,
latestVersion: unknown,
) => {
assertTrustedSender(event)
const url = validateUpdateUrl(downloadUrl)
const savePath = await downloadUpdateInstaller(event.sender, url, latestVersion)
const choice = await dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '更新已下载',
message: '新版安装包下载完成',
detail: '安装包已保存到:\n' + savePath + '\n\n点击“立即安装”继续升级,安装程序可能会提示关闭当前软件。',
buttons: ['立即安装', '打开文件夹', '稍后安装'],
defaultId: 0,
cancelId: 2,
noLink: true,
})
if (choice.response === 0) {
const openError = await shell.openPath(savePath)
if (openError) {
throw new Error('无法启动安装程序:' + openError)
}
} else if (choice.response === 1) {
shell.showItemInFolder(savePath)
}
})
ipcMain.handle('images:select-files', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择图片',
properties: ['openFile', 'multiSelections'],
filters: [{ name: '支持的图片', extensions: IMAGE_INPUT_EXTENSIONS }],
})
return result.canceled ? [] : result.filePaths
})
ipcMain.handle('images:select-folder', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择图片文件夹',
properties: ['openDirectory'],
})
return result.canceled ? [] : result.filePaths
})
ipcMain.handle('images:select-output', async (event) => {
assertTrustedSender(event)
const result = await dialog.showOpenDialog(mainWindow!, {
title: '选择输出目录',
properties: ['openDirectory', 'createDirectory'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('images:inspect', (event, paths: unknown) => {
assertTrustedSender(event)
return imageProcessor.inspect(validatePaths(paths))
})
ipcMain.handle('images:process', (event, options: ProcessOptions) => {
assertTrustedSender(event)
const validated: ProcessOptions = {
...options,
paths: validatePaths(options?.paths),
}
return imageProcessor.process(validated, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('images:progress', progress)
}
})
})
ipcMain.handle('images:cancel', (event) => {
assertTrustedSender(event)
imageProcessor.cancel()
})
ipcMain.handle('images:reveal', (event, filePath: unknown) => {
assertTrustedSender(event)
if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
throw new Error('文件路径无效')
}
shell.showItemInFolder(filePath)
})
}
function validateUpdateUrl(value: unknown): URL {
if (typeof value !== 'string') {
throw new Error('下载地址无效')
}
const url = new URL(value)
if (url.protocol !== 'https:') {
throw new Error('安装包必须通过 HTTPS 下载')
}
const extension = path.extname(decodeUrlPathname(url)).toLowerCase()
if (!UPDATE_INSTALLER_EXTENSIONS.has(extension)) {
throw new Error('下载地址不是受支持的 Windows 安装包')
}
return url
}
function decodeUrlPathname(url: URL): string {
try {
return decodeURIComponent(url.pathname)
} catch {
throw new Error('安装包下载地址格式不正确')
}
}
function installerFilename(url: URL, latestVersion: unknown): string {
const extension = path.extname(decodeUrlPathname(url)).toLowerCase()
const version = typeof latestVersion === 'string'
? latestVersion.trim().replace(/^v/i, '').replace(/[^0-9A-Za-z._-]/g, '').slice(0, 32)
: ''
return version
? '正版情趣王-Setup-' + version + extension
: '正版情趣王-Setup' + extension
}
function availableDownloadPath(filename: string): string {
const downloadsDirectory = app.getPath('downloads')
const extension = path.extname(filename)
const basename = path.basename(filename, extension)
let candidate = path.join(downloadsDirectory, filename)
for (let index = 2; existsSync(candidate); index += 1) {
candidate = path.join(downloadsDirectory, basename + '-' + index + extension)
}
return candidate
}
function downloadUpdateInstaller(
sender: WebContents,
url: URL,
latestVersion: unknown,
): Promise<string> {
const savePath = availableDownloadPath(installerFilename(url, latestVersion))
const browserSession = sender.session
return new Promise((resolve, reject) => {
let downloadItem: DownloadItem | null = null
let settled = false
const cleanup = (): void => {
clearTimeout(timeout)
browserSession.removeListener('will-download', handleDownload)
}
const finish = (error?: Error): void => {
if (settled) return
settled = true
cleanup()
if (error) reject(error)
else resolve(savePath)
}
const handleDownload = (
_event: ElectronEvent,
item: DownloadItem,
source: WebContents,
): void => {
if (source !== sender) return
downloadItem = item
browserSession.removeListener('will-download', handleDownload)
item.setSavePath(savePath)
item.once('done', (_doneEvent, state) => {
if (state === 'completed') {
finish()
} else {
finish(new Error(state === 'cancelled' ? '安装包下载已取消' : '安装包下载中断,请重试'))
}
})
}
const timeout = setTimeout(() => {
downloadItem?.cancel()
finish(new Error('安装包下载超时,请检查网络后重试'))
}, 30 * 60 * 1000)
browserSession.on('will-download', handleDownload)
try {
// 由 Electron 直接下载,避免依赖 Windows 的 HTTPS 默认应用关联。
sender.downloadURL(url.toString())
} catch (error) {
finish(error instanceof Error ? error : new Error('无法开始下载安装包'))
}
})
}
function assertTrustedSender(event: IpcMainInvokeEvent): void {
const senderUrl = event.senderFrame?.url || ''
if (senderUrl.startsWith('file://')) {
return
}
if (!app.isPackaged && senderUrl.startsWith('http://127.0.0.1:5173')) {
return
}
throw new Error('拒绝来自非可信页面的请求')
}
function validatePaths(value: unknown): string[] {
if (!Array.isArray(value) || value.length > 10_000) {
throw new Error('文件列表无效')
}
return value.map((item) => {
if (typeof item !== 'string' || !path.isAbsolute(item) || item.length > 4096) {
throw new Error('文件路径无效')
}
return item
})
}
if (singleInstance) {
app.whenReady().then(() => {
app.setAppUserModelId('com.jxxyk.genuineqingquwang')
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
registerIpcHandlers()
createWindow()
})
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
}
+170
View File
@@ -0,0 +1,170 @@
import { app } from 'electron'
import { createHash } from 'node:crypto'
import os from 'node:os'
import { machineIdSync } from 'node-machine-id'
import type {
AppInfo,
LicenseSession,
LicenseViewState,
VersionInfo,
} from '../../src/shared/types'
import { LicenseStore, type StoredLicense } from './license-store'
import {
DesktopServerApi,
type DevicePayload,
ServerApiError,
} from './server-api'
const OFFLINE_GRACE_MS = 72 * 60 * 60 * 1000
export class LicenseManager {
private readonly store = new LicenseStore()
private readonly server = new DesktopServerApi()
getAppInfo(): AppInfo {
return {
name: '正版情趣王',
version: app.getVersion(),
platform: process.platform,
deviceName: os.hostname(),
}
}
async getState(): Promise<LicenseViewState> {
const stored = await this.store.load()
if (!stored) {
return {
status: 'unlicensed',
message: '输入激活码后即可开始使用',
offline: false,
}
}
return this.validateStored(stored)
}
async activate(activationCode: string): Promise<LicenseViewState> {
try {
const session = await this.server.activate(activationCode, this.devicePayload())
if (!session.token) {
throw new ServerApiError('INVALID_RESPONSE', '授权服务未返回设备凭据')
}
await this.persistSession(session)
return {
status: 'active',
message: '设备已激活',
offline: false,
session,
version: session.version,
}
} catch (error) {
return this.errorState(error)
}
}
async validate(): Promise<LicenseViewState> {
const stored = await this.store.load()
if (!stored) {
return {
status: 'unlicensed',
message: '当前设备尚未激活',
offline: false,
}
}
return this.validateStored(stored)
}
async clear(): Promise<LicenseViewState> {
await this.store.clear()
return {
status: 'unlicensed',
message: '本机授权凭据已清除',
offline: false,
}
}
checkVersion(): Promise<VersionInfo> {
return this.server.checkVersion(app.getVersion())
}
private async validateStored(stored: StoredLicense): Promise<LicenseViewState> {
try {
const session = await this.server.login(stored.token, this.devicePayload())
session.token = stored.token
await this.persistSession(session)
return {
status: 'active',
message: '授权有效',
offline: false,
session,
version: session.version,
}
} catch (error) {
if (error instanceof ServerApiError && error.code.startsWith('NETWORK_')
&& this.canUseOffline(stored)) {
const session: LicenseSession = {
licenseCodeMask: stored.licenseCodeMask,
deviceMask: stored.deviceMask,
expiresAt: stored.expiresAt,
maxDevices: stored.maxDevices,
}
return {
status: 'active',
message: '当前处于离线授权模式,请在 72 小时内联网校验',
offline: true,
session,
}
}
return this.errorState(error)
}
}
private async persistSession(session: LicenseSession): Promise<void> {
if (!session.token) {
throw new Error('缺少设备授权凭据')
}
await this.store.save({
token: session.token,
licenseCodeMask: session.licenseCodeMask,
deviceMask: session.deviceMask,
expiresAt: session.expiresAt,
maxDevices: session.maxDevices,
lastVerifiedAt: new Date().toISOString(),
})
}
private canUseOffline(stored: StoredLicense): boolean {
const lastVerifiedAt = new Date(stored.lastVerifiedAt).getTime()
const expiresAt = stored.expiresAt ? new Date(stored.expiresAt).getTime() : Number.POSITIVE_INFINITY
return Number.isFinite(lastVerifiedAt)
&& Date.now() - lastVerifiedAt <= OFFLINE_GRACE_MS
&& Date.now() < expiresAt
}
private errorState(error: unknown): LicenseViewState {
if (error instanceof ServerApiError) {
return {
status: error.code.startsWith('NETWORK_') ? 'network-error' : 'invalid',
message: error.message,
offline: false,
}
}
return {
status: 'invalid',
message: error instanceof Error ? error.message : '授权校验失败',
offline: false,
}
}
private devicePayload(): DevicePayload {
const deviceId = createHash('sha256')
.update(`正版情趣王:${machineIdSync()}`)
.digest('hex')
return {
deviceId,
deviceName: os.hostname(),
osName: `${os.type()} ${os.arch()}`,
osVersion: os.release(),
appVersion: app.getVersion(),
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
export interface StoredLicense {
token: string
licenseCodeMask: string
deviceMask: string
expiresAt?: string
maxDevices: number
lastVerifiedAt: string
}
interface StoredLicenseFile {
encryptedToken: string
licenseCodeMask: string
deviceMask: string
expiresAt?: string
maxDevices: number
lastVerifiedAt: string
}
export class LicenseStore {
private get filePath(): string {
return path.join(app.getPath('userData'), 'license.json')
}
async load(): Promise<StoredLicense | null> {
try {
const raw = await fs.readFile(this.filePath, 'utf8')
const stored = JSON.parse(raw) as StoredLicenseFile
const decrypted = await safeStorage.decryptStringAsync(
Buffer.from(stored.encryptedToken, 'base64'),
)
return {
token: decrypted.result,
licenseCodeMask: stored.licenseCodeMask,
deviceMask: stored.deviceMask,
expiresAt: stored.expiresAt,
maxDevices: stored.maxDevices,
lastVerifiedAt: stored.lastVerifiedAt,
}
} catch {
return null
}
}
async save(license: StoredLicense): Promise<void> {
if (!(await safeStorage.isAsyncEncryptionAvailable())) {
throw new Error('系统安全凭据存储不可用')
}
const encryptedToken = await safeStorage.encryptStringAsync(license.token)
const stored: StoredLicenseFile = {
encryptedToken: encryptedToken.toString('base64'),
licenseCodeMask: license.licenseCodeMask,
deviceMask: license.deviceMask,
expiresAt: license.expiresAt,
maxDevices: license.maxDevices,
lastVerifiedAt: license.lastVerifiedAt,
}
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.tmp`
await fs.writeFile(temporaryPath, JSON.stringify(stored), { mode: 0o600 })
await fs.rename(temporaryPath, this.filePath)
}
async clear(): Promise<void> {
await fs.rm(this.filePath, { force: true })
}
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { buildDesktopRequestHeaders } from './server-api-headers'
describe('desktop server request headers', () => {
it('builds a Fetch-compatible ASCII client header', () => {
const headers = buildDesktopRequestHeaders('1.0.0')
expect(headers['X-Desktop-Client']).toBe('genuine-qingquwang/1.0.0')
expect(() => new Headers(headers)).not.toThrow()
})
it('rejects non-ASCII version labels before making a request', () => {
expect(() => buildDesktopRequestHeaders('版本-1.0.0')).toThrow(/ASCII/)
})
})
+15
View File
@@ -0,0 +1,15 @@
const DESKTOP_CLIENT_ID = 'genuine-qingquwang'
const VISIBLE_ASCII = /^[\x20-\x7E]+$/
export function buildDesktopRequestHeaders(version: string): Record<string, string> {
const clientHeader = `${DESKTOP_CLIENT_ID}/${version}`
// Fetch 请求头必须是 ByteString,禁止中文等非 ASCII 字符在发请求前触发异常。
if (!VISIBLE_ASCII.test(clientHeader)) {
throw new Error('桌面客户端版本标识必须使用 ASCII 字符')
}
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Desktop-Client': clientHeader,
}
}
+102
View File
@@ -0,0 +1,102 @@
import { app, net } from 'electron'
import type {
LicenseSession,
VersionInfo,
} from '../../src/shared/types'
import { buildDesktopRequestHeaders } from './server-api-headers'
interface ApiResult<T> {
code: string
msg: string
data: T
}
export interface DevicePayload {
deviceId: string
deviceName: string
osName: string
osVersion: string
appVersion: string
}
export class ServerApiError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message)
this.name = 'ServerApiError'
}
}
const DEFAULT_API_BASE_URL = 'https://apimall.v2.jxxyk.com/api'
export class DesktopServerApi {
private readonly baseUrl: string
constructor(baseUrl = process.env.IMAGE_TOOL_API_BASE_URL || DEFAULT_API_BASE_URL) {
this.baseUrl = this.validateBaseUrl(baseUrl)
}
activate(activationCode: string, device: DevicePayload): Promise<LicenseSession> {
return this.post<LicenseSession>('/desktop/activate', {
activationCode,
...device,
})
}
login(token: string, device: DevicePayload): Promise<LicenseSession> {
return this.post<LicenseSession>('/desktop/login', {
token,
...device,
})
}
checkVersion(currentVersion: string): Promise<VersionInfo> {
return this.post<VersionInfo>('/desktop/version/check', {
currentVersion,
platform: 'windows',
})
}
private async post<T>(pathname: string, body: unknown): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const response = await net.fetch(`${this.baseUrl}${pathname}`, {
method: 'POST',
headers: buildDesktopRequestHeaders(app.getVersion()),
body: JSON.stringify(body),
signal: controller.signal,
})
if (!response.ok) {
throw new ServerApiError(String(response.status), `授权服务暂时不可用(${response.status})`)
}
const result = (await response.json()) as ApiResult<T>
if (result.code !== '0') {
throw new ServerApiError(result.code, result.msg || '授权校验失败')
}
return result.data
} catch (error) {
if (error instanceof ServerApiError) {
throw error
}
if (error instanceof Error && error.name === 'AbortError') {
throw new ServerApiError('NETWORK_TIMEOUT', '连接授权服务超时,请检查网络后重试')
}
throw new ServerApiError('NETWORK_ERROR', '无法连接授权服务,请检查网络后重试')
} finally {
clearTimeout(timeout)
}
}
private validateBaseUrl(value: string): string {
const url = new URL(value.trim())
const localDevelopment = url.protocol === 'http:'
&& ['127.0.0.1', 'localhost'].includes(url.hostname)
if (url.protocol !== 'https:' && !(localDevelopment && !app.isPackaged)) {
throw new Error('生产授权服务必须使用 HTTPS')
}
return url.toString().replace(/\/$/, '')
}
}
+36
View File
@@ -0,0 +1,36 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import type {
DesktopBridge,
ProcessProgress,
} from '../../src/shared/types'
const bridge: DesktopBridge = {
getAppInfo: () => ipcRenderer.invoke('app:info'),
getLicenseState: () => ipcRenderer.invoke('license:state'),
activate: (activationCode) => ipcRenderer.invoke('license:activate', activationCode),
validateLicense: () => ipcRenderer.invoke('license:validate'),
clearLicense: () => ipcRenderer.invoke('license:clear'),
checkVersion: () => ipcRenderer.invoke('license:check-version'),
downloadUpdate: (downloadUrl, latestVersion) => ipcRenderer.invoke(
'license:download-update',
downloadUrl,
latestVersion,
),
selectImages: () => ipcRenderer.invoke('images:select-files'),
selectFolder: () => ipcRenderer.invoke('images:select-folder'),
selectOutputDirectory: () => ipcRenderer.invoke('images:select-output'),
pathsForFiles: (files) => files
.map((file) => webUtils.getPathForFile(file))
.filter(Boolean),
inspectImages: (paths) => ipcRenderer.invoke('images:inspect', paths),
processImages: (options) => ipcRenderer.invoke('images:process', options),
cancelProcessing: () => ipcRenderer.invoke('images:cancel'),
revealItem: (filePath) => ipcRenderer.invoke('images:reveal', filePath),
onProcessProgress: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, progress: ProcessProgress) => callback(progress)
ipcRenderer.on('images:progress', listener)
return () => ipcRenderer.removeListener('images:progress', listener)
},
}
contextBridge.exposeInMainWorld('desktop', bridge)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>正版情趣王</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/renderer/main.ts"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
{
"name": "genuine-qingquwang",
"version": "1.0.0",
"private": true,
"author": "星驿科技",
"description": "正版情趣王 JPEG 图片批处理桌面软件",
"main": "dist-electron/electron/main/index.js",
"scripts": {
"dev": "concurrently -k \"vite --host 127.0.0.1 --port 5173\" \"tsc -p tsconfig.electron.json --watch --preserveWatchOutput\" \"wait-on tcp:5173 file:dist-electron/electron/main/index.js && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
"build:renderer": "vite build",
"prebuild:electron": "node scripts/clean-electron.cjs",
"build:electron": "tsc -p tsconfig.electron.json",
"typecheck": "vue-tsc --noEmit -p tsconfig.renderer.json && tsc --noEmit -p tsconfig.electron.json",
"build": "pnpm run typecheck && pnpm run build:renderer && pnpm run build:electron",
"test": "vitest run",
"dist": "pnpm run build && electron-builder",
"dist:win": "pnpm run build && electron-builder --win --x64",
"icon:build": "node scripts/build-icon.cjs"
},
"build": {
"appId": "com.jxxyk.genuineqingquwang",
"productName": "正版情趣王",
"copyright": "Copyright © 2026 星驿科技",
"asar": true,
"asarUnpack": [
"node_modules/sharp/**/*",
"node_modules/@img/**/*"
],
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"package.json"
],
"win": {
"icon": "build/icon.png",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "正版情趣王-Setup-${version}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "正版情趣王",
"uninstallDisplayName": "正版情趣王"
},
"mac": {
"icon": "build/icon.png",
"category": "public.app-category.graphics-design",
"artifactName": "正版情趣王-${version}.${ext}"
}
},
"dependencies": {
"@lucide/vue": "^1.34.0",
"node-machine-id": "^1.1.12",
"sharp": "^0.35.3",
"vue": "^3.5.41"
},
"devDependencies": {
"@types/node": "^26.3.0",
"@vitejs/plugin-vue": "^6.0.8",
"concurrently": "^10.0.5",
"cross-env": "^10.1.0",
"electron": "^44.0.0",
"electron-builder": "^26.15.3",
"typescript": "^5.9.3",
"vite": "^8.2.2",
"vitest": "^4.1.11",
"vue-tsc": "^3.3.11",
"wait-on": "^9.1.0"
}
}
+3701
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
supportedArchitectures:
os:
- current
- win32
cpu:
- current
- x64
+16
View File
@@ -0,0 +1,16 @@
const fs = require('node:fs/promises')
const path = require('node:path')
const sharp = require('sharp')
async function main() {
const source = path.resolve(__dirname, '../build/icon.svg')
const output = path.resolve(__dirname, '../build/icon.png')
await fs.mkdir(path.dirname(output), { recursive: true })
await sharp(source).resize(512, 512).png().toFile(output)
process.stdout.write(`icon=${output}\n`)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+5
View File
@@ -0,0 +1,5 @@
const fs = require('node:fs')
const path = require('node:path')
// 编译前只清理可再生的 Electron 输出,避免旧测试产物被误打进安装包。
fs.rmSync(path.resolve(__dirname, '../dist-electron'), { recursive: true, force: true })
+15
View File
@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
import type { DesktopBridge } from './shared/types'
declare global {
interface Window {
desktop: DesktopBridge
}
}
declare module 'node-machine-id' {
export function machineIdSync(original?: boolean): string
}
export {}
+720
View File
@@ -0,0 +1,720 @@
<script setup lang="ts">
import {
AlertCircle,
BadgeCheck,
Check,
CheckCircle2,
ChevronRight,
CircleHelp,
Clock3,
Download,
FileImage,
FolderOpen,
HardDrive,
Images,
Info,
KeyRound,
ListRestart,
LoaderCircle,
LogOut,
Play,
Plus,
RefreshCw,
Settings2,
ShieldCheck,
Square,
Trash2,
UploadCloud,
X,
} from '@lucide/vue'
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue'
import type {
AppInfo,
ImageFileItem,
LicenseViewState,
OutputFormat,
ProcessProgress,
VersionInfo,
} from '../shared/types'
type NavigationKey = 'process' | 'license' | 'about'
type ToastTone = 'success' | 'error' | 'info'
const appInfo = ref<AppInfo>({
name: '正版情趣王',
version: '1.0.0',
platform: 'win32',
deviceName: '当前设备',
})
const licenseState = ref<LicenseViewState>({
status: 'checking',
message: '正在校验授权…',
offline: false,
})
const activeNavigation = ref<NavigationKey>('process')
const activationCode = ref('')
const activating = ref(false)
const items = ref<ImageFileItem[]>([])
const inspecting = ref(false)
const processing = ref(false)
const dragActive = ref(false)
const useSourceDirectory = ref(true)
const outputDirectory = ref('')
const outputFormat = ref<OutputFormat>('file')
const junkSizeMb = ref(1)
const progress = ref<ProcessProgress | null>(null)
const versionInfo = ref<VersionInfo | null>(null)
const checkingVersion = ref(false)
const downloadingUpdate = ref(false)
const toast = ref<{ message: string; tone: ToastTone } | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let disposeProgress: (() => void) | null = null
const totalSize = computed(() => items.value.reduce((sum, item) => sum + item.size, 0))
const completedCount = computed(() => items.value.filter((item) => item.status === 'completed').length)
const failedCount = computed(() => items.value.filter((item) => item.status === 'failed').length)
const pendingCount = computed(() => items.value.filter((item) => item.status === 'pending').length)
const forceUpdateRequired = computed(() => Boolean(
(versionInfo.value || licenseState.value.version)?.updateAvailable
&& (versionInfo.value || licenseState.value.version)?.enforce,
))
const canStart = computed(() => items.value.length > 0
&& !processing.value
&& !forceUpdateRequired.value
&& (useSourceDirectory.value || Boolean(outputDirectory.value)))
const currentVersion = computed(() => versionInfo.value || licenseState.value.version)
const currentReleaseNotes = computed(() => normalizeReleaseNotes(currentVersion.value?.releaseNotes))
const updateTitle = computed(() => {
const info = currentVersion.value
if (!info) return ''
if (!info.enforce) return info.latestVersion ? '发现新版本 ' + info.latestVersion : '发现新版本'
return info.latestVersion
? '必须升级至 ' + info.latestVersion + ' 后继续使用'
: '必须升级后继续使用'
})
onMounted(async () => {
loadPreferences()
disposeProgress = window.desktop.onProcessProgress(handleProgress)
try {
const [info, state] = await Promise.all([
window.desktop.getAppInfo(),
window.desktop.getLicenseState(),
])
appInfo.value = info
licenseState.value = state
versionInfo.value = state.version || null
if (import.meta.env.DEV
&& new URLSearchParams(window.location.search).get('demo') === 'queue'
&& state.status === 'active') {
await addPaths(await window.desktop.selectImages())
}
} catch (error) {
licenseState.value = {
status: 'network-error',
message: errorMessage(error),
offline: false,
}
}
})
watch([useSourceDirectory, outputDirectory, outputFormat, junkSizeMb], () => {
localStorage.setItem('image-processing-preferences', JSON.stringify({
useSourceDirectory: useSourceDirectory.value,
outputDirectory: outputDirectory.value,
outputFormat: outputFormat.value,
junkSizeMb: junkSizeMb.value,
}))
})
onBeforeUnmount(() => {
disposeProgress?.()
if (toastTimer) clearTimeout(toastTimer)
})
async function activate(): Promise<void> {
if (!activationCode.value.trim()) {
showToast('请输入激活码', 'error')
return
}
activating.value = true
try {
const state = await window.desktop.activate(activationCode.value.trim())
licenseState.value = state
versionInfo.value = state.version || null
if (state.status === 'active') {
activationCode.value = ''
showToast('当前设备已成功激活', 'success')
}
} finally {
activating.value = false
}
}
function formatActivationCode(): void {
const raw = activationCode.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 20)
activationCode.value = raw.match(/.{1,4}/g)?.join('-') || raw
}
async function retryLicense(): Promise<void> {
licenseState.value = {
status: 'checking',
message: '正在重新连接授权服务…',
offline: false,
}
licenseState.value = await window.desktop.validateLicense()
versionInfo.value = licenseState.value.version || null
}
async function clearLicense(): Promise<void> {
if (processing.value) {
showToast('请先等待当前处理任务结束', 'error')
return
}
licenseState.value = await window.desktop.clearLicense()
activeNavigation.value = 'process'
}
async function selectImages(): Promise<void> {
await addPaths(await window.desktop.selectImages())
}
async function selectFolder(): Promise<void> {
await addPaths(await window.desktop.selectFolder())
}
async function chooseOutputDirectory(): Promise<void> {
const selected = await window.desktop.selectOutputDirectory()
if (selected) {
outputDirectory.value = selected
useSourceDirectory.value = false
}
}
async function addPaths(paths: string[]): Promise<void> {
if (!paths.length || processing.value) return
inspecting.value = true
try {
const inspected = await window.desktop.inspectImages(paths)
const existing = new Set(items.value.map((item) => item.path))
const additions = inspected.filter((item) => !existing.has(item.path))
items.value.push(...additions)
if (!additions.length) {
showToast(inspected.length ? '所选图片已经在列表中' : '没有找到支持的图片', 'info')
} else {
showToast(`已添加 ${additions.length} 张图片`, 'success')
}
} catch (error) {
showToast(errorMessage(error), 'error')
} finally {
inspecting.value = false
}
}
async function handleDrop(event: DragEvent): Promise<void> {
dragActive.value = false
const files = Array.from(event.dataTransfer?.files || [])
if (!files.length) return
await addPaths(window.desktop.pathsForFiles(files))
}
function removeItem(id: string): void {
if (processing.value) return
items.value = items.value.filter((item) => item.id !== id)
}
function clearItems(): void {
if (processing.value) return
items.value = []
progress.value = null
}
async function startProcessing(): Promise<void> {
if (!canStart.value) return
processing.value = true
progress.value = null
items.value = items.value.map((item) => ({
...item,
status: 'pending',
outputPath: undefined,
error: undefined,
}))
try {
const summary = await window.desktop.processImages({
paths: items.value.map((item) => item.path),
useSourceDirectory: useSourceDirectory.value,
outputDirectory: outputDirectory.value || undefined,
outputFormat: outputFormat.value,
junkSizeMb: junkSizeMb.value,
})
if (summary.cancelled) {
showToast(`处理已取消,已完成 ${summary.completed} 张`, 'info')
} else if (summary.failed > 0) {
showToast(`处理完成:成功 ${summary.completed} 张,失败 ${summary.failed} 张`, 'error')
} else {
showToast(`全部完成,共生成 ${summary.completed} 个文件`, 'success')
}
} catch (error) {
showToast(errorMessage(error), 'error')
} finally {
processing.value = false
}
}
async function cancelProcessing(): Promise<void> {
await window.desktop.cancelProcessing()
}
function handleProgress(next: ProcessProgress): void {
progress.value = next
const index = items.value.findIndex((item) => item.path === next.item.path)
if (index >= 0) {
items.value[index] = { ...items.value[index], ...next.item }
}
}
async function reveal(filePath?: string): Promise<void> {
if (filePath) await window.desktop.revealItem(filePath)
}
async function checkVersion(): Promise<void> {
checkingVersion.value = true
try {
versionInfo.value = await window.desktop.checkVersion()
showToast(versionInfo.value.updateAvailable ? '发现新版本' : '当前已是最新版本', 'info')
} catch (error) {
showToast(errorMessage(error), 'error')
} finally {
checkingVersion.value = false
}
}
async function downloadUpdate(): Promise<void> {
const info = currentVersion.value
if (!info?.downloadUrl) {
showToast('该版本尚未配置安装包下载地址', 'error')
return
}
downloadingUpdate.value = true
try {
await window.desktop.downloadUpdate(info.downloadUrl, info.latestVersion)
showToast('安装包已下载到“下载”文件夹', 'success')
} catch (error) {
showToast(errorMessage(error), 'error')
} finally {
downloadingUpdate.value = false
}
}
function normalizeReleaseNotes(value?: string): string {
if (!value?.trim()) return ''
const parsed = new DOMParser().parseFromString(value, 'text/html')
parsed.querySelectorAll('script, style').forEach((element) => element.remove())
parsed.querySelectorAll('br, p, div, li, h1, h2, h3, h4, h5, h6')
.forEach((element) => element.append(' '))
return (parsed.body.textContent || '').replace(/\s+/g, ' ').trim()
}
function showToast(message: string, tone: ToastTone): void {
toast.value = { message, tone }
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.value = null
}, 3200)
}
function loadPreferences(): void {
try {
const saved = JSON.parse(localStorage.getItem('image-processing-preferences') || '{}') as {
useSourceDirectory?: boolean
outputDirectory?: string
outputFormat?: OutputFormat
junkSizeMb?: number
}
if (typeof saved.useSourceDirectory === 'boolean') useSourceDirectory.value = saved.useSourceDirectory
if (typeof saved.outputDirectory === 'string') outputDirectory.value = saved.outputDirectory
if (saved.outputFormat === 'file' || saved.outputFormat === 'file-jpg') outputFormat.value = saved.outputFormat
if (typeof saved.junkSizeMb === 'number' && saved.junkSizeMb >= 0 && saved.junkSizeMb <= 100) {
junkSizeMb.value = saved.junkSizeMb
}
} catch {
localStorage.removeItem('image-processing-preferences')
}
}
function normalizeJunkSize(): void {
const value = Number(junkSizeMb.value)
junkSizeMb.value = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 1
}
function statusText(status: ImageFileItem['status']): string {
return {
pending: '待处理',
processing: '处理中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}[status]
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function formatDate(value?: string): string {
if (!value) return '长期有效'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('zh-CN')
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error || '操作失败')
}
</script>
<template>
<div class="app-frame" @dragover.prevent="dragActive = true" @dragleave.self="dragActive = false" @drop.prevent="handleDrop">
<header class="titlebar">
<div class="brand-mini">
<span class="brand-mini-mark"><FileImage :size="17" :stroke-width="2.2" /></span>
<span>正版情趣王</span>
<span class="titlebar-edition">专业版</span>
</div>
<div class="titlebar-status" v-if="licenseState.status === 'active'">
<span class="status-dot" :class="{ offline: licenseState.offline }"></span>
{{ licenseState.offline ? '离线授权' : '授权在线' }}
</div>
</header>
<div v-if="licenseState.status === 'checking'" class="splash-screen">
<div class="splash-mark"><FileImage :size="42" /></div>
<h1>正版情趣王</h1>
<p>{{ licenseState.message }}</p>
<LoaderCircle class="spin" :size="22" />
</div>
<main v-else-if="licenseState.status !== 'active'" class="activation-shell">
<section class="activation-card">
<div class="activation-brand-panel">
<div class="activation-kicker"><BadgeCheck :size="16" /> 正版授权</div>
<div class="activation-visual" aria-hidden="true">
<div class="visual-shield"><FileImage :size="64" :stroke-width="1.55" /></div>
<span class="visual-orbit orbit-one"></span>
<span class="visual-orbit orbit-two"></span>
<span class="visual-check"><Check :size="18" /></span>
</div>
<div>
<h1>正版情趣王</h1>
<p>多格式图片批处理工作台</p>
</div>
<div class="activation-features">
<span><ShieldCheck :size="16" /> 设备安全绑定</span>
<span><HardDrive :size="16" /> 本地批量处理</span>
<span><RefreshCw :size="16" /> 版本持续同步</span>
</div>
</div>
<div class="activation-form-panel">
<div class="activation-heading">
<span class="eyebrow">DEVICE ACTIVATION</span>
<h2>激活当前设备</h2>
<p>输入服务商提供的激活码。激活后,此授权将绑定到设备「{{ appInfo.deviceName }}」。</p>
</div>
<label class="field-label" for="activationCode">激活码</label>
<div class="activation-input" :class="{ invalid: licenseState.status === 'invalid' }">
<KeyRound :size="20" />
<input
id="activationCode"
v-model="activationCode"
maxlength="24"
autocomplete="off"
spellcheck="false"
placeholder="JXIP-XXXX-XXXX-XXXX-XXXX"
@input="formatActivationCode"
@keyup.enter="activate"
/>
</div>
<div v-if="licenseState.status === 'invalid' || licenseState.status === 'network-error'" class="activation-error">
<AlertCircle :size="16" />
<span>{{ licenseState.message }}</span>
</div>
<button class="primary-button activation-button" :disabled="activating" @click="activate">
<LoaderCircle v-if="activating" class="spin" :size="18" />
<ShieldCheck v-else :size="18" />
{{ activating ? '正在激活…' : '激活并进入工作台' }}
</button>
<button v-if="licenseState.status === 'network-error'" class="text-button retry-button" @click="retryLicense">
<RefreshCw :size="15" /> 重新连接授权服务
</button>
<div class="activation-note">
<Info :size="15" />
<span>激活码和设备凭据将由系统安全存储保护,不会以明文保存在电脑中。</span>
</div>
</div>
</section>
<footer class="activation-footer">正版情趣王 {{ appInfo.version }} · 星驿科技提供技术支持</footer>
</main>
<div v-else class="workspace-shell">
<aside class="sidebar">
<div class="sidebar-profile">
<div class="profile-mark"><ShieldCheck :size="21" /></div>
<div>
<strong>正版授权</strong>
<span>{{ licenseState.session?.deviceMask }}</span>
</div>
</div>
<nav class="sidebar-navigation" aria-label="主导航">
<button :class="{ active: activeNavigation === 'process' }" @click="activeNavigation = 'process'">
<Images :size="19" /><span>批量处理</span><ChevronRight :size="15" />
</button>
<button :class="{ active: activeNavigation === 'license' }" @click="activeNavigation = 'license'">
<ShieldCheck :size="19" /><span>授权信息</span><ChevronRight :size="15" />
</button>
<button :class="{ active: activeNavigation === 'about' }" @click="activeNavigation = 'about'">
<CircleHelp :size="19" /><span>关于软件</span><ChevronRight :size="15" />
</button>
</nav>
<div class="sidebar-bottom">
<div class="version-chip">v{{ appInfo.version }}</div>
<span>本地处理 · 图片不上传</span>
</div>
</aside>
<section v-if="activeNavigation === 'process'" class="workspace-main">
<div v-if="currentVersion?.updateAvailable" class="update-banner" :class="{ force: currentVersion.enforce }">
<div class="update-banner-icon"><Download :size="19" /></div>
<div>
<strong>{{ updateTitle }}</strong>
<span>{{ currentReleaseNotes || '新版本已准备好,可以下载安装。' }}</span>
</div>
<button :disabled="downloadingUpdate" @click="downloadUpdate">
<LoaderCircle v-if="downloadingUpdate" class="spin" :size="14" />
<Download v-else :size="14" />
{{ downloadingUpdate ? '正在下载…' : '立即升级' }}
</button>
</div>
<header class="workspace-header">
<div>
<span class="eyebrow">IMAGE WORKBENCH</span>
<h1>图片批量处理</h1>
<p>使用 AI 混淆算法处理图片,并生成指定的交付文件格式。</p>
</div>
<div class="process-rail" aria-label="处理步骤">
<div class="rail-step active"><span>1</span><b>导入图片</b></div>
<div class="rail-line" :class="{ active: items.length > 0 }"></div>
<div class="rail-step" :class="{ active: items.length > 0 }"><span>2</span><b>设置参数</b></div>
<div class="rail-line" :class="{ active: processing || completedCount > 0 }"></div>
<div class="rail-step" :class="{ active: processing || completedCount > 0 }"><span>3</span><b>生成文件</b></div>
</div>
</header>
<div class="processor-layout">
<section class="queue-card">
<div class="queue-toolbar">
<div class="queue-title">
<h2>处理队列</h2>
<span v-if="items.length">{{ items.length }} 张 · {{ formatBytes(totalSize) }}</span>
</div>
<div class="queue-actions">
<button class="secondary-button" :disabled="processing" @click="selectImages"><Plus :size="16" /> 添加图片</button>
<button class="secondary-button" :disabled="processing" @click="selectFolder"><FolderOpen :size="16" /> 添加文件夹</button>
<button class="icon-button" title="清空列表" :disabled="processing || !items.length" @click="clearItems"><Trash2 :size="17" /></button>
</div>
</div>
<div v-if="!items.length" class="drop-zone" :class="{ active: dragActive }" @click="selectImages">
<div class="drop-visual">
<div class="file-tile tile-back"><FileImage :size="25" /></div>
<div class="file-tile tile-front"><UploadCloud :size="28" /></div>
</div>
<h3>{{ inspecting ? '正在读取图片…' : '拖入图片或文件夹' }}</h3>
<p>支持 JPG、PNG、WebP、GIF、TIFF、AVIF、SVG;动图取首帧</p>
<button class="primary-quiet-button"><Plus :size="16" /> 选择图片</button>
</div>
<div v-else class="queue-table">
<div class="queue-table-head">
<span>文件</span><span>尺寸</span><span>大小</span><span>状态</span><span></span>
</div>
<div class="queue-rows">
<div v-for="item in items" :key="item.id" class="queue-row" :class="`is-${item.status}`">
<div class="file-cell">
<div class="file-icon"><FileImage :size="19" /></div>
<div>
<strong :title="item.name">{{ item.name }}</strong>
<span :title="item.outputPath || item.path">{{ item.outputPath || item.path }}</span>
</div>
</div>
<span class="data-cell">{{ item.width && item.height ? `${item.width} × ${item.height}` : '—' }}</span>
<span class="data-cell">{{ formatBytes(item.size) }}</span>
<div class="status-cell" :class="item.status">
<LoaderCircle v-if="item.status === 'processing'" class="spin" :size="15" />
<CheckCircle2 v-else-if="item.status === 'completed'" :size="15" />
<AlertCircle v-else-if="item.status === 'failed'" :size="15" />
<Clock3 v-else :size="15" />
<span :title="item.error">{{ item.error || statusText(item.status) }}</span>
</div>
<button v-if="item.outputPath" class="row-action" title="在文件夹中显示" @click="reveal(item.outputPath)"><FolderOpen :size="16" /></button>
<button v-else class="row-action" title="移除" :disabled="processing" @click="removeItem(item.id)"><X :size="16" /></button>
</div>
</div>
</div>
<div v-if="processing || progress" class="progress-dock">
<div class="progress-meta">
<div>
<strong>{{ processing ? '正在生成交付文件' : failedCount ? '处理已结束,部分文件失败' : '处理完成' }}</strong>
<span>{{ progress?.current || 0 }} / {{ progress?.total || items.length }}</span>
</div>
<b>{{ progress?.percent || 0 }}%</b>
</div>
<div class="progress-track"><span :style="{ width: `${progress?.percent || 0}%` }"></span></div>
</div>
</section>
<aside class="settings-card">
<div class="settings-heading"><Settings2 :size="18" /><h2>输出设置</h2></div>
<div class="setting-group">
<label>保存位置</label>
<button class="path-picker" :disabled="processing" @click="chooseOutputDirectory">
<FolderOpen :size="17" />
<span>{{ useSourceDirectory ? '图片原目录' : (outputDirectory || '选择输出目录') }}</span>
<ChevronRight :size="16" />
</button>
<label class="check-row">
<input v-model="useSourceDirectory" type="checkbox" :disabled="processing" />
<span class="custom-check"><Check :size="13" /></span>
<span>在每张图片的原目录生成</span>
</label>
</div>
<div class="setting-group">
<label>文件格式</label>
<div class="format-switch">
<button :class="{ active: outputFormat === 'file' }" :disabled="processing" @click="outputFormat = 'file'">
<b>.file</b><span>标准交付</span>
</button>
<button :class="{ active: outputFormat === 'file-jpg' }" :disabled="processing" @click="outputFormat = 'file-jpg'">
<b>.file.jpg</b><span>兼容扩展名</span>
</button>
</div>
</div>
<div class="setting-group">
<div class="setting-label-row">
<label for="junkSize">追加数据</label>
<div class="junk-size-input">
<input v-model.number="junkSizeMb" type="number" min="0" max="100" step="0.1" :disabled="processing" @change="normalizeJunkSize" />
<span>MB</span>
</div>
</div>
<input id="junkSize" v-model.number="junkSizeMb" class="range-control" type="range" min="0" max="100" step="0.1" :disabled="processing" />
<div class="range-labels"><span>0 MB</span><span>100 MB</span></div>
<p class="setting-help">用于控制 AI 混淆算法强度,默认值为 1 MB。</p>
</div>
<div class="algorithm-note">
<div><BadgeCheck :size="17" /><strong>AI 混淆算法已启用</strong></div>
<p>自动改变图片数据特征,同时保持视觉内容和交付兼容性。</p>
</div>
<div class="start-area">
<button v-if="processing" class="cancel-button" @click="cancelProcessing"><Square :size="16" fill="currentColor" /> 停止处理</button>
<button v-else class="primary-button start-button" :disabled="!canStart" @click="startProcessing">
<Play :size="18" fill="currentColor" /> 开始批量处理
</button>
<span v-if="items.length">待处理 {{ pendingCount }} · 已完成 {{ completedCount }} · 失败 {{ failedCount }}</span>
<span v-else>添加图片后即可开始</span>
</div>
</aside>
</div>
</section>
<section v-else-if="activeNavigation === 'license'" class="detail-page">
<header class="detail-header">
<span class="eyebrow">LICENSE CENTER</span>
<h1>授权信息</h1>
<p>查看当前设备绑定与授权有效期。</p>
</header>
<div class="license-hero-card">
<div class="license-shield"><ShieldCheck :size="42" /></div>
<div class="license-hero-copy">
<span class="license-status"><span></span>{{ licenseState.offline ? '离线授权有效' : '正版授权有效' }}</span>
<h2>{{ licenseState.session?.licenseCodeMask }}</h2>
<p>当前设备已通过服务端校验,可以使用全部图片处理功能。</p>
</div>
<div class="license-expiry"><span>有效期至</span><strong>{{ formatDate(licenseState.session?.expiresAt) }}</strong></div>
</div>
<div class="detail-grid">
<div class="detail-card"><span>设备名称</span><strong>{{ appInfo.deviceName }}</strong><small>{{ licenseState.session?.deviceMask }}</small></div>
<div class="detail-card"><span>允许设备数</span><strong>{{ licenseState.session?.maxDevices || 1 }} 台</strong><small>设备数量由激活码策略控制</small></div>
<div class="detail-card"><span>授权模式</span><strong>{{ licenseState.offline ? '离线宽限' : '在线校验' }}</strong><small>{{ licenseState.message }}</small></div>
</div>
<div class="danger-zone">
<div><strong>解除本机授权凭据</strong><p>清除后需要重新输入激活码;不会自动释放服务端设备绑定。</p></div>
<button class="danger-outline-button" @click="clearLicense"><LogOut :size="16" /> 清除本机凭据</button>
</div>
</section>
<section v-else class="detail-page">
<header class="detail-header">
<span class="eyebrow">ABOUT PRODUCT</span>
<h1>关于正版情趣王</h1>
<p>支持多种图片输入、统一生成 JPEG 交付文件的本地批量处理工具。</p>
</header>
<div class="about-card">
<div class="about-mark"><FileImage :size="42" /></div>
<div class="about-copy">
<h2>正版情趣王</h2>
<p>专业多格式图片批处理软件</p>
<span>版本 {{ appInfo.version }}</span>
</div>
<button class="secondary-button" :disabled="checkingVersion" @click="checkVersion">
<LoaderCircle v-if="checkingVersion" class="spin" :size="16" />
<RefreshCw v-else :size="16" />
检查更新
</button>
</div>
<div v-if="currentVersion?.updateAvailable" class="release-card">
<div><Download :size="20" /><div><strong>新版本 {{ currentVersion.latestVersion }}</strong><p>{{ currentReleaseNotes || '版本已准备好。' }}</p></div></div>
<button class="primary-button" :disabled="downloadingUpdate" @click="downloadUpdate">
<LoaderCircle v-if="downloadingUpdate" class="spin" :size="14" />
{{ downloadingUpdate ? '正在下载…' : '下载安装' }}
</button>
</div>
<div class="about-features">
<div><HardDrive :size="21" /><strong>全程本地</strong><p>图片处理过程不上传服务器。</p></div>
<div><BadgeCheck :size="21" /><strong>AI 混淆算法</strong><p>自动完成图片特征混淆与兼容处理。</p></div>
<div><ListRestart :size="21" /><strong>批量队列</strong><p>文件与文件夹统一加入处理队列。</p></div>
</div>
</section>
</div>
<div v-if="dragActive && licenseState.status === 'active'" class="drop-overlay" @dragleave.prevent="dragActive = false" @drop.prevent="handleDrop">
<div><UploadCloud :size="42" /><strong>松开即可添加到处理队列</strong><span>支持 JPG、PNG、WebP、GIF、TIFF、AVIF、SVG 和文件夹</span></div>
</div>
<Transition name="toast">
<div v-if="toast" class="toast-message" :class="toast.tone">
<CheckCircle2 v-if="toast.tone === 'success'" :size="18" />
<AlertCircle v-else-if="toast.tone === 'error'" :size="18" />
<Info v-else :size="18" />
{{ toast.message }}
</div>
</Transition>
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
import { createMockDesktopBridge } from './mock-bridge'
if (import.meta.env.DEV && !window.desktop) {
window.desktop = createMockDesktopBridge()
}
createApp(App).mount('#app')
+106
View File
@@ -0,0 +1,106 @@
import type {
DesktopBridge,
ImageFileItem,
LicenseViewState,
ProcessProgress,
VersionInfo,
} from '../shared/types'
/**
* 仅供普通浏览器中的视觉验收使用;Electron 环境始终由 preload 提供真实能力。
*/
export function createMockDesktopBridge(): DesktopBridge {
const mode = new URLSearchParams(window.location.search).get('demo') || 'workspace'
const progressListeners = new Set<(progress: ProcessProgress) => void>()
const availableUpdate: VersionInfo = {
updateAvailable: true,
latestVersion: 'v1.0.1',
releaseNotes: '<p>1、优化体验</p>',
downloadUrl: 'https://cdn.example.com/genuine-qingquwang.exe',
enforce: true,
}
const activeLicense: LicenseViewState = {
status: 'active',
message: '授权有效',
offline: false,
session: {
licenseCodeMask: 'JXIP-****-****-****-8Q7M',
deviceMask: 'A2C91D…7F06B8',
expiresAt: '2027-08-26T06:00:00+08:00',
maxDevices: 1,
},
version: mode === 'update' ? availableUpdate : undefined,
}
return {
getAppInfo: async () => ({
name: '正版情趣王',
version: '1.0.0',
platform: 'win32',
deviceName: 'DESKTOP-TENCENT',
}),
getLicenseState: async () => mode === 'activation'
? { status: 'unlicensed', message: '输入激活码后即可开始使用', offline: false }
: activeLicense,
activate: async () => activeLicense,
validateLicense: async () => activeLicense,
clearLicense: async () => ({ status: 'unlicensed', message: '本机授权凭据已清除', offline: false }),
checkVersion: async () => mode === 'update'
? availableUpdate
: { updateAvailable: false, enforce: false },
downloadUpdate: async () => undefined,
selectImages: async () => [
'C:\\Product\\主图-蓝色.jpg',
'C:\\Product\\详情页-01.jpg',
'C:\\Product\\详情页-02.jpg',
],
selectFolder: async () => ['C:\\Product\\新品批次'],
selectOutputDirectory: async () => 'D:\\正版情趣王\\输出',
pathsForFiles: () => [],
inspectImages: async (paths) => paths.flatMap((filePath, pathIndex) => {
const count = filePath.endsWith('新品批次') ? 4 : 1
return Array.from({ length: count }, (_, nestedIndex): ImageFileItem => {
const index = pathIndex + nestedIndex + 1
const name = count > 1 ? `新品-${String(index).padStart(2, '0')}.jpg` : filePath.split('\\').pop() || `图片-${index}.jpg`
return {
id: `${filePath}-${nestedIndex}`,
path: count > 1 ? `${filePath}\\${name}` : filePath,
name,
size: 485_000 + index * 127_000,
width: 1200,
height: 1600,
status: 'pending',
}
})
}),
processImages: async (options) => {
for (let index = 0; index < options.paths.length; index++) {
const item: ImageFileItem = {
id: options.paths[index],
path: options.paths[index],
name: options.paths[index].split('\\').pop() || '图片.jpg',
size: 612_000,
width: 1200,
height: 1600,
status: 'completed',
outputPath: `${options.paths[index]}.${options.outputFormat === 'file-jpg' ? 'file.jpg' : 'file'}`,
}
progressListeners.forEach((listener) => listener({
current: index + 1,
total: options.paths.length,
percent: Math.round(((index + 1) / options.paths.length) * 100),
item,
cancelled: false,
}))
}
return { total: options.paths.length, completed: options.paths.length, failed: 0, cancelled: false }
},
cancelProcessing: async () => undefined,
revealItem: async () => undefined,
onProcessProgress: (callback) => {
progressListeners.add(callback)
return () => progressListeners.delete(callback)
},
}
}
+277
View File
@@ -0,0 +1,277 @@
:root {
font-family: "HarmonyOS Sans SC", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
color: #202938;
background: #f4f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--blue: #1769e0;
--blue-deep: #0d55bd;
--blue-pale: #edf4ff;
--teal: #1cab9c;
--ink: #202938;
--muted: #717b8d;
--subtle: #99a2b0;
--line: #e4e9f0;
--canvas: #f4f7fb;
--surface: #ffffff;
--danger: #d94f4f;
--shadow: 0 14px 40px rgba(31, 49, 79, 0.09);
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { min-width: 1080px; min-height: 680px; }
button, input { font: inherit; }
button { border: 0; }
button:focus-visible, input:focus-visible { outline: 3px solid rgba(23, 105, 224, 0.2); outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: 0.52; }
.app-frame { width: 100%; height: 100%; background: var(--canvas); position: relative; }
.titlebar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 156px 0 18px;
background: #f7f9fc;
border-bottom: 1px solid #e9edf3;
-webkit-app-region: drag;
user-select: none;
}
.brand-mini { display: flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 650; color: #293244; letter-spacing: 0.01em; }
.brand-mini-mark { display: grid; place-items: center; width: 27px; height: 27px; color: white; background: var(--blue); border-radius: 7px; }
.titlebar-edition { padding: 2px 7px; color: #5f6d83; background: #e9eef6; border-radius: 4px; font-size: 10px; font-weight: 600; }
.titlebar-status { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #6d7787; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #1bad84; box-shadow: 0 0 0 3px rgba(27, 173, 132, 0.12); }
.status-dot.offline { background: #e0a62e; box-shadow: 0 0 0 3px rgba(224, 166, 46, 0.14); }
.splash-screen { height: calc(100% - 48px); display: flex; flex-direction: column; align-items: center; justify-content: center; color: #758094; }
.splash-mark { width: 76px; height: 76px; display: grid; place-items: center; color: white; background: var(--blue); border-radius: 20px; box-shadow: 0 18px 36px rgba(23, 105, 224, 0.22); }
.splash-screen h1 { margin: 22px 0 6px; color: var(--ink); font-size: 25px; }
.splash-screen p { margin: 0 0 20px; font-size: 13px; }
.spin { animation: spin 0.85s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.activation-shell { height: calc(100% - 48px); overflow: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 38px; background-color: #f1f5fa; background-image: radial-gradient(circle at 1px 1px, rgba(67, 90, 123, 0.075) 1px, transparent 0); background-size: 24px 24px; }
.activation-card { width: min(980px, 92vw); min-height: 570px; display: grid; grid-template-columns: 0.86fr 1.14fr; overflow: hidden; background: white; border: 1px solid rgba(218, 225, 235, 0.9); border-radius: 18px; box-shadow: 0 30px 70px rgba(35, 55, 84, 0.14); }
.activation-brand-panel { position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; padding: 42px 40px; color: white; background: #1769e0; }
.activation-brand-panel::before { content: ""; position: absolute; inset: 0; opacity: 0.18; background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0); background-size: 20px 20px; mask-image: linear-gradient(to bottom right, black, transparent 72%); }
.activation-brand-panel > * { position: relative; z-index: 1; }
.activation-kicker { display: flex; align-items: center; gap: 7px; width: max-content; padding: 7px 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; font-size: 12px; font-weight: 650; letter-spacing: .08em; }
.activation-visual { position: relative; width: 220px; height: 210px; margin: 5px auto -4px; display: grid; place-items: center; }
.visual-shield { width: 142px; height: 142px; display: grid; place-items: center; color: var(--blue); background: white; border-radius: 44% 44% 50% 50% / 34% 34% 62% 62%; box-shadow: 0 20px 50px rgba(8, 42, 98, 0.24); }
.visual-orbit { position: absolute; inset: 18px; border: 1px solid rgba(255,255,255,.25); border-radius: 50%; transform: rotate(-14deg); }
.orbit-two { inset: 2px 34px; transform: rotate(40deg); }
.visual-check { position: absolute; right: 28px; bottom: 29px; display: grid; place-items: center; width: 36px; height: 36px; color: #0a6b61; background: #72eadc; border: 5px solid var(--blue); border-radius: 50%; }
.activation-brand-panel h1 { margin: 0; font-size: 31px; letter-spacing: .02em; }
.activation-brand-panel p { margin: 8px 0 0; color: rgba(255,255,255,.72); font-size: 14px; }
.activation-features { display: flex; gap: 14px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.18); }
.activation-features span { display: flex; align-items: center; gap: 6px; color: rgba(255,255,255,.8); font-size: 11px; white-space: nowrap; }
.activation-form-panel { display: flex; flex-direction: column; justify-content: center; padding: 56px 68px; }
.eyebrow { color: var(--blue); font-family: Bahnschrift, "DIN Alternate", sans-serif; font-size: 11px; font-weight: 700; letter-spacing: .14em; }
.activation-heading h2 { margin: 12px 0 10px; color: #1d2635; font-size: 28px; line-height: 1.25; }
.activation-heading p { max-width: 470px; margin: 0 0 34px; color: #778195; font-size: 13px; line-height: 1.8; }
.field-label { display: block; margin-bottom: 9px; color: #404959; font-size: 13px; font-weight: 650; }
.activation-input { height: 54px; display: flex; align-items: center; gap: 11px; padding: 0 16px; color: #8190a6; background: #fbfcfe; border: 1px solid #dce3ec; border-radius: 9px; transition: border .18s, box-shadow .18s; }
.activation-input:focus-within { color: var(--blue); border-color: var(--blue); box-shadow: 0 0 0 4px rgba(23,105,224,.08); }
.activation-input.invalid { border-color: #e8a2a2; }
.activation-input input { width: 100%; border: 0; outline: 0; color: #253044; background: transparent; font-family: Bahnschrift, Consolas, monospace; font-size: 17px; letter-spacing: .055em; }
.activation-input input::placeholder { color: #aeb7c5; }
.activation-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 10px; color: #c64747; font-size: 12px; line-height: 1.5; }
.activation-error svg { flex: 0 0 auto; margin-top: 1px; }
.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; color: white; background: var(--blue); border-radius: 7px; font-weight: 650; cursor: pointer; transition: background .18s, transform .18s, box-shadow .18s; }
.primary-button:hover:not(:disabled) { background: var(--blue-deep); box-shadow: 0 8px 20px rgba(23,105,224,.2); transform: translateY(-1px); }
.activation-button { width: 100%; height: 48px; margin-top: 20px; }
.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--blue); background: transparent; cursor: pointer; }
.retry-button { margin: 12px auto 0; font-size: 12px; }
.activation-note { display: flex; align-items: flex-start; gap: 8px; margin-top: 28px; padding-top: 20px; color: #8a93a2; border-top: 1px solid #edf0f4; font-size: 11px; line-height: 1.6; }
.activation-note svg { flex: 0 0 auto; margin-top: 1px; }
.activation-footer { margin-top: 22px; color: #9aa4b2; font-size: 11px; }
.workspace-shell { height: calc(100% - 48px); display: grid; grid-template-columns: 218px 1fr; }
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 20px 14px 16px; background: #f8fafc; border-right: 1px solid var(--line); }
.sidebar-profile { display: flex; align-items: center; gap: 11px; padding: 12px; background: white; border: 1px solid #e7ebf1; border-radius: 9px; }
.profile-mark { width: 36px; height: 36px; display: grid; place-items: center; color: #0b776a; background: #dff8f3; border-radius: 8px; }
.sidebar-profile div:last-child { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.sidebar-profile strong { font-size: 12px; }
.sidebar-profile span { overflow: hidden; color: #8b94a4; font-family: Bahnschrift, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-navigation { display: flex; flex-direction: column; gap: 5px; margin-top: 24px; }
.sidebar-navigation button { height: 43px; display: grid; grid-template-columns: 22px 1fr 15px; align-items: center; gap: 9px; padding: 0 12px; color: #687386; background: transparent; border-radius: 7px; text-align: left; cursor: pointer; transition: background .16s, color .16s; }
.sidebar-navigation button:hover { color: #344155; background: #eef2f7; }
.sidebar-navigation button.active { color: var(--blue); background: #eaf2ff; font-weight: 650; }
.sidebar-navigation button svg:last-child { opacity: 0; transform: translateX(-3px); transition: opacity .16s, transform .16s; }
.sidebar-navigation button.active svg:last-child { opacity: 1; transform: translateX(0); }
.sidebar-bottom { margin-top: auto; display: flex; flex-direction: column; gap: 8px; padding: 0 7px; color: #99a2b0; font-size: 10px; }
.version-chip { width: max-content; padding: 4px 7px; color: #66748a; background: #e9eef4; border-radius: 4px; font-family: Bahnschrift, sans-serif; }
.workspace-main, .detail-page { min-width: 0; min-height: 0; overflow: auto; padding: 28px 30px 30px; }
.update-banner { min-height: 58px; display: grid; grid-template-columns: 36px 1fr auto; align-items: center; gap: 12px; margin-bottom: 18px; padding: 10px 12px; color: #1555a9; background: #eaf3ff; border: 1px solid #cfe2ff; border-radius: 9px; }
.update-banner.force { color: #9b5620; background: #fff4e6; border-color: #f2d3aa; }
.update-banner-icon { width: 34px; height: 34px; display: grid; place-items: center; background: rgba(255,255,255,.72); border-radius: 7px; }
.update-banner div:nth-child(2) { display: flex; flex-direction: column; gap: 4px; }
.update-banner strong { font-size: 12px; }
.update-banner span { overflow: hidden; font-size: 11px; opacity: .76; text-overflow: ellipsis; white-space: nowrap; }
.update-banner button { display: flex; align-items: center; gap: 5px; padding: 8px 10px; color: inherit; background: white; border-radius: 6px; font-size: 11px; font-weight: 650; cursor: pointer; }
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 40px; margin-bottom: 22px; }
.workspace-header h1, .detail-header h1 { margin: 8px 0 5px; color: #1d2635; font-size: 25px; line-height: 1.25; }
.workspace-header p, .detail-header p { margin: 0; color: #7c8696; font-size: 12px; }
.process-rail { display: flex; align-items: center; min-width: 390px; padding: 15px 18px; background: white; border: 1px solid var(--line); border-radius: 10px; }
.rail-step { display: flex; align-items: center; gap: 7px; color: #a0a8b4; white-space: nowrap; }
.rail-step span { width: 23px; height: 23px; display: grid; place-items: center; color: #8994a6; background: #eef1f5; border-radius: 50%; font-family: Bahnschrift, sans-serif; font-size: 10px; }
.rail-step b { font-size: 11px; font-weight: 550; }
.rail-step.active { color: #334055; }
.rail-step.active span { color: white; background: var(--blue); }
.rail-line { width: 34px; height: 2px; margin: 0 8px; background: #e4e8ee; transition: background .25s; }
.rail-line.active { background: var(--blue); }
.processor-layout { height: calc(100% - 105px); min-height: 570px; display: grid; grid-template-columns: minmax(0, 1fr) 306px; gap: 18px; }
.queue-card, .settings-card { background: white; border: 1px solid var(--line); border-radius: 11px; box-shadow: 0 4px 15px rgba(43, 59, 84, 0.035); }
.queue-card { min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.queue-toolbar { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 18px; border-bottom: 1px solid #edf0f4; }
.queue-title { display: flex; align-items: baseline; gap: 9px; }
.queue-title h2, .settings-heading h2 { margin: 0; font-size: 14px; }
.queue-title span { color: #919aa8; font-size: 10px; }
.queue-actions { display: flex; gap: 7px; }
.secondary-button, .icon-button, .primary-quiet-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 10px; color: #526074; background: white; border: 1px solid #dfe5ed; border-radius: 6px; font-size: 11px; cursor: pointer; transition: color .16s, border .16s, background .16s; }
.secondary-button:hover:not(:disabled), .icon-button:hover:not(:disabled) { color: var(--blue); border-color: #aecaf1; background: #f8fbff; }
.icon-button { width: 32px; padding: 0; }
.drop-zone { flex: 1; margin: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 390px; background: #fbfcfe; border: 1px dashed #cfd8e5; border-radius: 10px; cursor: pointer; transition: border .18s, background .18s, transform .18s; }
.drop-zone:hover, .drop-zone.active { background: #f4f8ff; border-color: var(--blue); }
.drop-visual { position: relative; width: 104px; height: 88px; margin-bottom: 18px; }
.file-tile { position: absolute; width: 58px; height: 66px; display: grid; place-items: center; border-radius: 9px; }
.tile-back { left: 14px; top: 4px; color: #7ea9e5; background: #e5f0ff; transform: rotate(-9deg); }
.tile-front { right: 10px; bottom: 2px; color: white; background: var(--blue); box-shadow: 0 12px 26px rgba(23,105,224,.18); transform: rotate(5deg); }
.drop-zone h3 { margin: 0 0 8px; font-size: 15px; }
.drop-zone p { margin: 0 0 18px; color: #929baa; font-size: 11px; }
.primary-quiet-button { color: var(--blue); background: #edf4ff; border-color: transparent; font-weight: 650; }
.queue-table { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.queue-table-head, .queue-row { display: grid; grid-template-columns: minmax(250px, 1fr) 100px 82px 110px 32px; align-items: center; gap: 10px; }
.queue-table-head { min-height: 39px; padding: 0 18px; color: #98a1af; background: #fafbfd; border-bottom: 1px solid #eef1f5; font-size: 10px; }
.queue-rows { flex: 1; min-height: 0; overflow: auto; }
.queue-row { min-height: 64px; padding: 8px 18px; border-bottom: 1px solid #f0f2f5; transition: background .15s; }
.queue-row:hover { background: #fafcff; }
.queue-row.is-processing { background: #f7faff; }
.file-cell { min-width: 0; display: flex; align-items: center; gap: 10px; }
.file-icon { flex: 0 0 auto; width: 34px; height: 34px; display: grid; place-items: center; color: var(--blue); background: #eaf3ff; border-radius: 7px; }
.file-cell > div:last-child { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.file-cell strong, .file-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-cell strong { color: #374154; font-size: 11px; font-weight: 650; }
.file-cell span { color: #9aa3b1; font-size: 9px; }
.data-cell { color: #747f90; font-family: Bahnschrift, "Microsoft YaHei UI", sans-serif; font-size: 10px; }
.status-cell { min-width: 0; display: flex; align-items: center; gap: 5px; color: #8792a3; font-size: 10px; }
.status-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-cell.processing { color: var(--blue); }
.status-cell.completed { color: #149173; }
.status-cell.failed { color: var(--danger); }
.row-action { width: 28px; height: 28px; display: grid; place-items: center; color: #919baa; background: transparent; border-radius: 5px; cursor: pointer; }
.row-action:hover:not(:disabled) { color: var(--blue); background: #edf4ff; }
.progress-dock { padding: 14px 18px; border-top: 1px solid #e8ecf2; background: #fbfcfe; }
.progress-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
.progress-meta div { display: flex; align-items: baseline; gap: 8px; }
.progress-meta strong { font-size: 11px; }
.progress-meta span { color: #929baa; font-family: Bahnschrift, sans-serif; font-size: 10px; }
.progress-meta b { color: var(--blue); font-family: Bahnschrift, sans-serif; font-size: 11px; }
.progress-track { height: 5px; overflow: hidden; background: #e7ebf1; border-radius: 3px; }
.progress-track span { display: block; height: 100%; background: var(--blue); border-radius: inherit; transition: width .2s ease; }
.settings-card { display: flex; flex-direction: column; padding: 18px; overflow: auto; }
.settings-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
.settings-heading svg { color: var(--blue); }
.setting-group { padding: 16px 0; border-bottom: 1px solid #edf0f4; }
.setting-group > label, .setting-label-row label { display: block; margin-bottom: 9px; color: #4c5667; font-size: 11px; font-weight: 650; }
.path-picker { width: 100%; height: 39px; display: grid; grid-template-columns: 18px 1fr 16px; align-items: center; gap: 7px; padding: 0 10px; color: #566277; background: #f9fafc; border: 1px solid #e1e6ed; border-radius: 7px; cursor: pointer; }
.path-picker span { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
.path-picker svg:last-child { color: #a2aab7; }
.check-row { position: relative; display: flex !important; align-items: center; gap: 7px; margin: 10px 0 0 !important; color: #758092 !important; font-size: 10px !important; font-weight: 400 !important; cursor: pointer; }
.check-row input { position: absolute; opacity: 0; }
.custom-check { width: 17px; height: 17px; display: grid; place-items: center; color: transparent; background: white; border: 1px solid #cfd7e2; border-radius: 4px; }
.check-row input:checked + .custom-check { color: white; background: var(--blue); border-color: var(--blue); }
.format-switch { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.format-switch button { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 11px; color: #697488; background: #fafbfd; border: 1px solid #e2e7ee; border-radius: 7px; cursor: pointer; }
.format-switch button.active { color: var(--blue); background: #f1f6ff; border-color: #9fc2f3; box-shadow: inset 0 0 0 1px rgba(23,105,224,.08); }
.format-switch b { font-family: Bahnschrift, Consolas, monospace; font-size: 12px; }
.format-switch span { font-size: 9px; opacity: .72; }
.setting-label-row { display: flex; align-items: center; justify-content: space-between; }
.setting-label-row label { margin: 0; }
.junk-size-input { display: flex; align-items: center; overflow: hidden; height: 28px; background: #f8fafc; border: 1px solid #dde4ed; border-radius: 6px; }
.junk-size-input input { width: 58px; height: 100%; padding: 0 6px; color: var(--blue); background: transparent; border: 0; outline: 0; font-family: Bahnschrift, sans-serif; font-size: 11px; text-align: right; }
.junk-size-input span { padding-right: 7px; color: #8993a2; font-size: 9px; }
.range-control { width: 100%; height: 4px; margin: 16px 0 7px; accent-color: var(--blue); cursor: pointer; }
.range-labels { display: flex; justify-content: space-between; color: #a0a8b5; font-family: Bahnschrift, sans-serif; font-size: 8px; }
.setting-help { margin: 10px 0 0; color: #949dab; font-size: 9px; line-height: 1.6; }
.algorithm-note { margin-top: 15px; padding: 12px; color: #34605a; background: #edf9f6; border: 1px solid #d7efe9; border-radius: 8px; }
.algorithm-note div { display: flex; align-items: center; gap: 7px; }
.algorithm-note strong { font-size: 10px; }
.algorithm-note p { margin: 7px 0 0; color: #6e8e89; font-size: 9px; line-height: 1.55; }
.start-area { margin-top: auto; padding-top: 18px; }
.start-button, .cancel-button { width: 100%; height: 43px; }
.cancel-button { display: flex; align-items: center; justify-content: center; gap: 8px; color: #b44444; background: #fff2f2; border: 1px solid #efcaca; border-radius: 7px; font-weight: 650; cursor: pointer; }
.start-area > span { display: block; margin-top: 9px; color: #9aa3b0; font-size: 9px; text-align: center; }
.detail-page { padding: 40px 48px; }
.detail-header { margin-bottom: 24px; }
.license-hero-card { display: grid; grid-template-columns: 72px 1fr auto; align-items: center; gap: 20px; padding: 28px; color: white; background-color: #1769e0; background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,.16) 1px, transparent 0); background-size: 22px 22px; border-radius: 12px; box-shadow: 0 18px 40px rgba(23,105,224,.18); }
.license-shield { width: 68px; height: 68px; display: grid; place-items: center; color: var(--blue); background: white; border-radius: 18px; }
.license-status { display: flex; align-items: center; gap: 7px; color: rgba(255,255,255,.76); font-size: 10px; }
.license-status span { width: 7px; height: 7px; background: #70ead9; border-radius: 50%; }
.license-hero-copy h2 { margin: 9px 0 7px; font-family: Bahnschrift, Consolas, monospace; font-size: 22px; letter-spacing: .04em; }
.license-hero-copy p { margin: 0; color: rgba(255,255,255,.72); font-size: 11px; }
.license-expiry { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; padding-left: 30px; border-left: 1px solid rgba(255,255,255,.2); }
.license-expiry span { color: rgba(255,255,255,.64); font-size: 10px; }
.license-expiry strong { font-size: 14px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 18px; }
.detail-card { display: flex; flex-direction: column; min-height: 132px; padding: 20px; background: white; border: 1px solid var(--line); border-radius: 10px; }
.detail-card span { color: #8993a2; font-size: 10px; }
.detail-card strong { margin: 15px 0 7px; font-size: 18px; }
.detail-card small { margin-top: auto; color: #9ba4b2; font-size: 9px; }
.danger-zone { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding: 20px; background: white; border: 1px solid #f0dede; border-radius: 10px; }
.danger-zone strong { font-size: 12px; }
.danger-zone p { margin: 6px 0 0; color: #929aa7; font-size: 10px; }
.danger-outline-button { display: flex; align-items: center; gap: 6px; padding: 9px 12px; color: #c14949; background: white; border: 1px solid #e7baba; border-radius: 6px; font-size: 10px; cursor: pointer; }
.about-card { display: grid; grid-template-columns: 64px 1fr auto; align-items: center; gap: 18px; padding: 25px; background: white; border: 1px solid var(--line); border-radius: 11px; }
.about-mark { width: 62px; height: 62px; display: grid; place-items: center; color: white; background: var(--blue); border-radius: 16px; }
.about-copy h2 { margin: 0 0 5px; font-size: 20px; }
.about-copy p { margin: 0 0 8px; color: #7f8999; font-size: 11px; }
.about-copy span { color: #a0a8b5; font-family: Bahnschrift, sans-serif; font-size: 9px; }
.release-card { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; padding: 18px; background: #edf4ff; border: 1px solid #d4e4fa; border-radius: 10px; }
.release-card > div { display: flex; align-items: flex-start; gap: 12px; color: var(--blue); }
.release-card strong { color: #2e3c51; font-size: 12px; }
.release-card p { margin: 5px 0 0; color: #748095; font-size: 10px; }
.release-card button { padding: 9px 14px; }
.about-features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 14px; }
.about-features > div { min-height: 150px; display: flex; flex-direction: column; padding: 20px; color: var(--blue); background: white; border: 1px solid var(--line); border-radius: 10px; }
.about-features strong { margin-top: 20px; color: #313c4f; font-size: 12px; }
.about-features p { margin: 7px 0 0; color: #8d96a4; font-size: 10px; line-height: 1.6; }
.drop-overlay { position: fixed; z-index: 50; inset: 48px 0 0; display: grid; place-items: center; padding: 32px; background: rgba(238, 245, 254, .9); backdrop-filter: blur(6px); }
.drop-overlay > div { width: min(620px, 70vw); height: 310px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--blue); background: white; border: 2px dashed #81adea; border-radius: 16px; box-shadow: var(--shadow); }
.drop-overlay strong { margin-top: 18px; color: #2f3b4e; font-size: 18px; }
.drop-overlay span { margin-top: 8px; color: #8c96a5; font-size: 11px; }
.toast-message { position: fixed; z-index: 80; left: 50%; bottom: 28px; display: flex; align-items: center; gap: 8px; max-width: 520px; padding: 11px 15px; color: #2d384a; background: white; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 14px 34px rgba(36, 51, 74, .16); font-size: 11px; transform: translateX(-50%); }
.toast-message.success svg { color: #159271; }
.toast-message.error svg { color: var(--danger); }
.toast-message.info svg { color: var(--blue); }
.toast-enter-active, .toast-leave-active { transition: opacity .2s, transform .2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
@media (max-width: 1220px) {
.workspace-shell { grid-template-columns: 196px 1fr; }
.workspace-main { padding: 24px 22px; }
.process-rail { min-width: 350px; }
.processor-layout { grid-template-columns: minmax(0, 1fr) 286px; }
.activation-form-panel { padding: 50px 48px; }
}
/* 保证小屏笔记本在允许的最小窗口高度下仍能看到核心处理操作。 */
@media (max-height: 760px) {
.workspace-main { padding-top: 18px; padding-bottom: 18px; }
.workspace-header { margin-bottom: 14px; }
.processor-layout { height: calc(100% - 91px); min-height: 0; }
.settings-card { padding: 14px; }
.setting-group { padding: 12px 0; }
.algorithm-note { margin-top: 10px; padding: 10px; }
.start-area { padding-top: 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
+90
View File
@@ -0,0 +1,90 @@
export type OutputFormat = 'file' | 'file-jpg'
export type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'
export interface ImageFileItem {
id: string
path: string
name: string
size: number
width: number | null
height: number | null
status: ImageStatus
outputPath?: string
error?: string
}
export interface ProcessOptions {
paths: string[]
useSourceDirectory: boolean
outputDirectory?: string
outputFormat: OutputFormat
junkSizeMb: number
}
export interface ProcessProgress {
current: number
total: number
percent: number
item: ImageFileItem
cancelled: boolean
}
export interface ProcessSummary {
total: number
completed: number
failed: number
cancelled: boolean
}
export interface VersionInfo {
updateAvailable: boolean
latestVersion?: string
releaseNotes?: string
downloadUrl?: string
enforce: boolean
}
export interface LicenseSession {
token?: string
licenseCodeMask: string
deviceMask: string
expiresAt?: string
maxDevices: number
version?: VersionInfo
}
export type LicenseStatus = 'checking' | 'unlicensed' | 'active' | 'invalid' | 'network-error'
export interface LicenseViewState {
status: LicenseStatus
message: string
offline: boolean
session?: LicenseSession
version?: VersionInfo
}
export interface AppInfo {
name: string
version: string
platform: string
deviceName: string
}
export interface DesktopBridge {
getAppInfo(): Promise<AppInfo>
getLicenseState(): Promise<LicenseViewState>
activate(activationCode: string): Promise<LicenseViewState>
validateLicense(): Promise<LicenseViewState>
clearLicense(): Promise<LicenseViewState>
checkVersion(): Promise<VersionInfo>
downloadUpdate(downloadUrl: string, latestVersion?: string): Promise<void>
selectImages(): Promise<string[]>
selectFolder(): Promise<string[]>
selectOutputDirectory(): Promise<string | null>
pathsForFiles(files: File[]): string[]
inspectImages(paths: string[]): Promise<ImageFileItem[]>
processImages(options: ProcessOptions): Promise<ProcessSummary>
cancelProcessing(): Promise<void>
revealItem(filePath: string): Promise<void>
onProcessProgress(callback: (progress: ProcessProgress) => void): () => void
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"outDir": "dist-electron",
"rootDir": ".",
"types": ["node", "electron"],
"skipLibCheck": true
},
"include": ["electron/**/*.ts", "src/shared/**/*.ts"],
"exclude": ["electron/**/*.spec.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.renderer.json" },
{ "path": "./tsconfig.electron.json" }
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/*"],
"@shared/*": ["src/shared/*"]
},
"skipLibCheck": true
},
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.vue", "src/shared/**/*.ts", "src/env.d.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDirectory = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(currentDirectory, 'src/renderer'),
'@shared': path.resolve(currentDirectory, 'src/shared'),
},
},
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
},
test: {
include: ['electron/**/*.spec.ts'],
exclude: ['node_modules/**', 'dist/**', 'dist-electron/**', 'release/**'],
},
})