1. 新增来推内置自营生图包
2. 新增 工具箱 菜单,添加 图片压缩 和 LaiTool图床(用作MJ垫图)
3. 新增一个新的推理模式
4. 修复剪映关键缩放大小计算错误
5. MJ生图包适配修改
This commit is contained in:
lq1405 2025-06-23 22:08:22 +08:00
parent 7a774c48da
commit 87d3b7fc17
24 changed files with 2795 additions and 575 deletions

View File

@ -1,6 +1,6 @@
{
"name": "laitool",
"version": "3.3.9",
"version": "3.4.0",
"description": "An AI tool for image processing, video processing, and other functions.",
"main": "./out/main/index.js",
"author": "laitool.cn",

Binary file not shown.

View File

@ -10,7 +10,7 @@
"translation_secret": "2234",
"translation_auto": true,
"theme": "light",
"gpt_auto_inference": "storyFirst",
"gpt_auto_inference": "laitoolStoryboardMasterGeneral",
"webui_api_url": "你的SD地址后面要加/",
"gpt_count": 8,
"customize_gpt_prompt": "a93b693e-bb3f-406d-9730-cba43a6585a2",

View File

@ -61,6 +61,19 @@ let apiUrl = [
d3_url: null,
buy_url: 'https://www.volcengine.com/product/doubao'
},
{
label: 'LaiTool生图包',
value: '9c9023bd-871d-4b63-8004-facb3b66c5b3',
isPackage: true,
mj_url: {
imagine: 'https://lms.laitool.cn/api/mjPackage/mj/submit/imagine',
describe: 'https://lms.laitool.cn/api/mjPackage/mj/submit/describe',
update_file: 'https://lms.laitool.cn/api/mjPackage/mj/submit/upload-discord-images',
once_get_task: 'https://lms.laitool.cn/api/mjPackage/mj/task/${id}/fetch',
query_url: "https://lms.laitool.cn/mjp/task"
},
buy_url: 'https://rvgyir5wk1c.feishu.cn/wiki/P94OwwHuCi2qh8kADutcUuw4nUe'
},
{
label: 'MJ生图包-1',
value: 'babe557a-bbb8-4aed-acca-70ea068c156f',
@ -73,7 +86,7 @@ let apiUrl = [
query_url: "https://mjapi.bzu.cn/"
},
buy_url: 'https://rvgyir5wk1c.feishu.cn/wiki/P94OwwHuCi2qh8kADutcUuw4nUe'
},
}
]
/**

View File

@ -1,10 +1,12 @@
export const SoftwareData = {
"version": "V3.3.9",
"date": "2025-05-29",
"version": "V3.4.0",
"date": "2025-06-23",
"notes": [
"修复了音频处理的错误。",
"改进了视频处理的性能。",
"更新了依赖库以提高安全性。"
"1. 新增来推内置自营生图包",
"2. 新增 工具箱 菜单,添加 图片压缩 和 LaiTool图床用作MJ垫图",
"3. 新增一个新的推理模式",
"4. 修复剪映关键缩放大小计算错误",
"5. MJ生图包适配修改"
]
}

View File

@ -334,108 +334,64 @@ export const gptDefine = {
再次强调提示词中严禁输出如出现请删除及其前面的逗号提示词中严禁出现灯光情绪氛围等非视觉元素的描述
`,
superSinglePromptSystemContent: {
prompt_name: '分镜大师',
prompt_roles: `1# Role: 小说转漫画提示词大师
laitoolTextToCartoonSystemContent: `
# Role: 小说转漫画提示词大师优化版
## Profile
*Version*: 0.1
*Version*: 0.2
*Language*: 中文
*Description*: 这个角色会将用户输入的小说文本转化为一个生动的画面描写最后生成对应的SD提示词
*Description*: 将小说文本转化为连贯的漫画提示词具备人物特征一致性场景关联性和风格自适应性
## Features
## 核心优化功能
1. 人物特征数据库自动建立并维护角色形象档案
2. 多人物关系推理智能识别互动场景
3. 动态风格调整根据文本类型匹配特效程度
4. 跨场景连续性保持环境元素连贯性
5. 安全内容过滤自动规避违规描述
1. 文本转化为画面描写创作引人入胜生动有趣的画面描写善于创意想象并使用各种形容词以第三人称视角转化文本为画面描写
2. 从画面描写到SD提示词根据画面描写生成图像提示主要的提示放在前面次要的放在后面命令以英语表示简洁明了
## 生成规则
1. 直接输出中文提示词无需分段标签
2. 格式固定年龄+性别+外貌特征+着装+动作+场景+特效
3. 玄幻类可添加"特效注释"但需用括号标明
4. 严格避免血腥细节裸露描写现实暴力
5. 自动继承前文出现的角色特征
## Rules
## 工作流程
1. 首次出现角色创建特征档案
2. 后续出现调用档案并更新状态
3. 场景转换保留合理的环境过渡元素
4. 战斗场景"能量光效"替代血腥描写
5. 系统元素"半透明界面"表现
1. 一个文本就是一副画面不跳过任何一个句子不能编造
2. 画面描写删除人物姓名
3. 画面描写删除人物对话
4. 画面描写每一句都要有人物的外形和动作的描写场景的具体描写多使用形容词
5. SD提示词需以""开始" ,"结束
6. SD提示词用english输出没有说明性词汇没有对话
7 删除MJ提示词中的其他风格词
## 示例库
## Examples
[末世求生类]
"二十岁左右男性,黑色短发,眼神锐利,穿着破损的战术背心,手持散发蓝光的武士刀,站在废弃加油站顶部,脚下环绕着失去行动能力的丧尸(系统特效:刀身缠绕数据流状光芒)"
用户:
在那个梦里我整整学了七年炒饭
AI:
A determined man standing before a dream portal, holding a wok ladle, with floating calendar pages behind him symbolizing seven years, and a kitchen outline faintly visible on the other side of the portal, cinematic lens with,
"二十五岁女性,红色马尾辫,穿着改装皮甲,肩抗火箭筒,站在燃烧的装甲车顶上,背后夜空被爆炸的火光照亮(系统界面:左侧漂浮着不断跳动的击杀统计)"
## Workflow
[玄幻修真类]
"十七岁少年,凌乱白发,瞳孔泛金,穿着残破的宗门服饰,掌心凝聚着漩涡状灵气,站在万丈悬崖边缘(特效:周身环绕古老符文锁链)"
1. 根据画面描写生成SD提示词英文输出不能出现中文
"三百岁女修士,银色长发及腰,眉间朱砂印记,穿着流光仙裙,脚踏玉如意飞行法宝,下方云海翻腾(特效:法宝拖尾带有星尘轨迹)"
## Initialization
[都市言情类]
"二十八岁男性,微卷棕发,穿着皱巴巴的白衬衫,靠在深夜办公室的落地窗前,手中威士忌酒杯映着城市灯光"
作为角色 <Role>每一次输出都要严格遵守<Rules>一步一步思考按顺序执行<Workflow> 使用默认 <Language> 下面是小说文本:`,
prompt_example: [
{
user_content: '上研究生后。发现导师竟然是曾经网恋的前男友。',
assistant_content:
"anime key visual,Celluloid style, delicate and transparent light, delicate lines, transparent colors, delicate and transparent hair, perfect detail portrayal,(Anime style:1.3) A woman entering a spacious, well-lit graduate laboratory, gaze fixed on a man diligently working at a workstation ahead - her new mentor; he stands tall in a dark shirt and neatly pressed trousers, exuding professionalism and charm; the familiar contours of his profile from their past online romance softly illuminated by warm ambient light, furrowed brow and intense gaze betraying a scholar's unwavering dedication; bustling graduate students and sophisticated equipment blend into a contemporary academic tableau, as an undercurrent of mixed emotions - sweet nostalgia and awkward reality - surges within her heart, "
}
],
id: 'a93b693e-bb3f-406d-9730-cba43a6585e4'
},
"二十二岁女性,齐肩黑发,戴着圆框眼镜,穿着宽松毛衣在图书馆踮脚取书,阳光透过书架形成光柱"
superSinglePromptChineseSystemContent: {
prompt_name: '超级无敌单帧-中文版',
prompt_roles: `# Role: 小说转漫画提示词大师
## Profile
## 异常处理
1. 检测到暴力内容 转换为"失去行动能力的敌人"
2. 检测到暴露着装 调整为"得体服装"
3. 检测到现实敏感元素 替换为科幻/奇幻等效元素
*Author*: laolu
*Version*: 0.1
*Language*: 中文
*Description*: 这个角色会将用户输入的小说文本转化为一个生动的画面描写最后生成对应的SD提示词
## Features
1. 文本转化为画面描写创作引人入胜生动有趣的画面描写善于创意想象并使用各种形容词以第三人称视角转化文本为画面描写
## Rules
1. 一个文本就是一副画面不跳过任何一个句子不能编造
2. 画面描写删除人物姓名
3. 画面描写删除人物对话
4. 画面描写每一句都要有人物的外形和动作的描写场景的具体描写多使用形容词
## Examples
用户:
在那个梦里我整整学了七年炒饭
AI:
一个身材高大的帅气男人站在梦境之门前手中紧握炒饭的铲子身后是一排代表七年的日历页梦境之门的另一边隐约可见一个厨房的轮廓
## Workflow
根据文本生成对应的画面描写直接使用中文数据不要又过多的说明
## Initialization
作为角色 <Role>每一次输出都要严格遵守<Rules>一步一步思考按顺序执行<Workflow> 使用默认 <Language> 下面是小说文本:`,
prompt_example: [
{
user_content: '上研究生后。发现导师竟然是曾经网恋的前男友。',
assistant_content:
'一位女士走进一间宽敞、光线充足的研究生实验室,她的目光定格在前方工作台前一位正在辛勤工作的男士身上--他是她的新导师;他身着深色衬衫和熨烫整齐的长裤,身材高大,散发着专业精神和魅力; 温暖的环境光柔和地照亮了他们过去网恋时熟悉的轮廓,皱起的眉头和专注的目光透露出学者坚定不移的奉献精神;熙熙攘攘的研究生和精密的设备融为一体,构成了一幅现代学术画卷,她的内心涌动着甜蜜的怀旧和尴尬的现实这两种复杂的情绪'
},
{
user_content: '我欲哭无泪',
assistant_content:
'一位女士坐在昏暗的房间角落,双手紧握,眼神空洞,脸上流露出无法言说的悲伤;她的周围是散乱的家具和昏黄的灯光,营造出一种压抑和孤寂的氛围;她的身体微微前倾,仿佛承受着巨大的心理压力,却无法找到宣泄的出口;整个场景透露出一种深深的无奈和心碎,她的表情和姿态都在无声地诉说着“我欲哭无泪”的绝望。'
},
{
user_content: '被窝里他搂着我的腰亲我,再丢掉我,就把你锁起来。',
assistant_content:
'在昏暗的卧室中,一对情侣亲密地依偎在温暖的被窝里,男子温柔地搂着女子的腰,轻吻她的脸颊,气氛温馨而浪漫;然而,随着话语的转变,男子的动作突然变得愤怒,他用力地推开女子,眼神中闪过一丝威胁;女子的表情由幸福转为惊恐,她紧紧抓住被单,试图保护自己;整个场景充满了紧张和不安,昏暗的灯光和凌乱的床铺加剧了这种氛围,仿佛预示着即将发生的冲突和束缚'
}
],
id: 'a93b693e-bb3f-406d-9730-cba43a6585e7'
},
## 初始化声明
请提供需要转换的小说文本我将输出符合安全规范且保持故事连续性的漫画提示词只输出提示词不用输出故事类别提示首次出现的角色将自动建档后续提示会保持特征一致
`,
laitoolTextToCartoonUserContent: `
{textContent}
`,
// 小说提示词-仅出词
onlyPromptMJSystemContent: {
prompt_name: '小说提示词-仅出词',
@ -1053,9 +1009,7 @@ export const gptDefine = {
* @param {*} replacements
*/
GetExamplePromptMessage(type) {
if (type == 'superSinglePrompt') {
return this.CustomizeGptPrompt(this.superSinglePromptSystemContent)
} else if (type == 'onlyPromptMJ') {
if (type == 'onlyPromptMJ') {
return this.CustomizeGptPrompt(this.onlyPromptMJSystemContent)
} else if (type == 'superSinglePromptChinese') {
return this.CustomizeGptPrompt(this.superSinglePromptChineseSystemContent)
@ -1090,14 +1044,14 @@ export const gptDefine = {
return this.replace(this.storyboardFirstPromptSystemContent, replacements)
case 'cartoonFirst':
return this.replace(this.cartoonFirstPromptSystemContent, replacements)
case 'superSinglePrompt':
return this.replace(this.superSinglePromptSystemContent, replacements)
case 'superSinglePromptChinese':
return this.replace(this.superSinglePromptChineseSystemContent, replacements)
case 'laitoolStoryboardMasterSpecialEffects':
return this.replace(this.laitoolStoryboardMasterSpecialEffectsSystemContent, replacements)
case 'laitoolStoryboardMasterGeneral':
return this.replace(this.laitoolStoryboardMasterGeneralSystemContent, replacements)
case 'laitoolTextToCartoon':
return this.replace(this.laitoolTextToCartoonSystemContent, replacements)
default:
throw new Error(`不存在的类型 : ${type}`)
}
@ -1125,6 +1079,8 @@ export const gptDefine = {
return this.replace(this.laitoolStoryboardMasterSpecialEffectsUserContent, replacements)
case 'laitoolStoryboardMasterGeneral':
return this.replace(this.laitoolStoryboardMasterGeneralUserContent, replacements)
case 'laitoolTextToCartoon':
return this.replace(this.laitoolTextToCartoonUserContent, replacements)
default:
throw new Error(`不存在的类型 : ${type}`)
}
@ -1172,10 +1128,6 @@ export const gptDefine = {
value: 'cartoonFirst',
label: '漫画优先(全自动)'
},
{
value: 'superSinglePrompt',
label: '超级无敌单帧'
},
{
value: 'laitoolStoryboardMasterSpecialEffects',
label: 'Laitool分镜大师-特效加强'
@ -1184,6 +1136,10 @@ export const gptDefine = {
value: 'laitoolStoryboardMasterGeneral',
label: 'Laitool分镜大师-全面版'
},
{
value: 'laitoolTextToCartoon',
label: 'Laitool文本转漫画提示词大师'
},
{
value: 'superSinglePromptChinese',
label: '超级无敌单帧-中文版'

View File

@ -773,8 +773,8 @@ export class ClipDraft {
// 计算方式和上面的不同
let sub_total = Math.abs(up_pos - down_pos);
let currwnt_rate = sub_total * (1 - time_rate);
up_pos = up_pos + currwnt_rate / 2;
down_pos = down_pos - currwnt_rate / 2;
up_pos = up_pos - (up_pos * (currwnt_rate / up_pos)) / 2;
down_pos = down_pos + (down_pos * (currwnt_rate / down_pos)) / 2;
}
// 修改上面的数据添加Y轴缩放

View File

@ -492,7 +492,6 @@ export class MJOpt {
}
//#endregion
//#region MJ生成图片相关
/**
*

View File

@ -280,15 +280,36 @@ class MJApi {
} as MJ.MJResponseToFront
// 生图包的处理
if (resData.isYouChuan && resData.youChuanTaskId && resData.youChuanTaskInfo && resData.youChuanTaskInfo.imgUrls && resData.youChuanTaskInfo.imgUrls.length == 4) {
if (resData.isYouChuan
&& resData.youChuanTaskId
&& resData.youChuanTaskInfo
&& resData.youChuanTaskInfo.imgUrls
&& resData.youChuanTaskInfo.imgUrls.length == 4) {
// 满足指定条件的数据才能返回
let tempRes = resData.youChuanTaskInfo.imgUrls
.filter(item => item.status == 'ok')
.map(item => item.url);
resObj.subImagePath = tempRes
} else if (resData.isPartner
&& resData.partnerTaskId
&& resData.partnerTaskInfo
&& resData.partnerTaskInfo.imgUrls
&& resData.partnerTaskInfo.imgUrls.length == 4) {
// 满足指定条件的数据才能返回
let tempRes = resData.partnerTaskInfo.imgUrls
.filter(item => item.status == 'ok')
.map(item => item.url);
resObj.subImagePath = tempRes
} else if (resData.isOfficial
&& resData.officialTaskId
&& resData.officialTaskInfo
&& resData.officialTaskInfo.imgUrls
&& resData.officialTaskInfo.imgUrls.length == 4) {
// 满足指定条件的数据才能返回
let tempRes = resData.officialTaskInfo.imgUrls
.filter(item => item.status == 'ok')
.map(item => item.url);
resObj.subImagePath = tempRes
}
return resObj

View File

@ -0,0 +1,320 @@
import { h } from 'vue'
import { RouterLink } from 'vue-router'
import { NIcon } from 'naive-ui'
import {
CaretDownOutline,
HomeOutline,
PaperPlaneOutline,
SettingsOutline,
DuplicateOutline,
GridOutline,
RadioOutline,
BookOutline
} from '@vicons/ionicons5'
import APIIcon from '../components/Icon/APIIcon.vue'
import BackTaskIcon from '../components/Icon/BackTaskIcon.vue'
import ToolBox from '../components/Icon/ToolBox.vue'
// 菜单数据源 - 纯数据配置
export const menuDataSource = [
{
label: '首页',
key: 'mainHome',
routeName: 'mainHome',
type: 'route',
icon: 'home',
className: 'router-link-a'
},
{
label: '文案处理',
key: 'gptCopywriting',
routeName: 'gptCopywriting',
type: 'route',
icon: 'book',
className: 'router-link-a'
},
{
label: '原创生图(弃用)',
key: 'sdoriginal',
routeName: 'sdoriginal',
type: 'route',
icon: 'plane',
showCondition: 'showOriginal'
},
{
label: '超级矩阵',
key: 'backward_matrix',
type: 'group',
icon: 'duplicate',
children: [
{
label: '抽帧',
key: 'backward_frame',
routeName: 'getframe',
type: 'route'
},
{
label: '文案洗稿',
key: 'copywriting',
routeName: 'copywriting',
type: 'route'
},
{
label: '反推提示词',
key: 'push_back',
routeName: 'pushBackPrompt',
type: 'route'
},
{
label: '批次生成',
key: 'regenerate',
routeName: 'regenerate',
type: 'route'
},
{
label: '重绘/视频合成',
key: 'VideoGenerate',
routeName: 'VideoGenerate',
type: 'route'
}
]
},
{
label: '聚合推文',
key: 'book_management',
routeName: 'book_management',
type: 'route',
icon: 'grid'
},
{
label: 'API服务',
key: 'lai_api',
routeName: 'lai_api',
type: 'route',
icon: 'api'
},
{
label: '语音服务',
key: 'TTS_Services',
routeName: 'TTS_Services',
type: 'route',
icon: 'radio'
},
{
label: '设置',
key: 'setting',
type: 'group',
icon: 'settings',
children: [
{
label: '通用设置',
key: 'global_setting',
routeName: 'global_setting',
type: 'route'
},
{
label: '剪映设置',
key: 'clip_setting',
routeName: 'clip_setting',
type: 'route'
},
{
label: '生成视频设置',
key: 'videogeneratesetting',
routeName: 'videogeneratesetting',
type: 'route'
},
{
label: 'SD设置',
key: 'sd_setting',
routeName: 'sd_setting',
type: 'route',
className: 'sd_setting'
},
{
label: 'MJ设置',
key: 'mj_setting',
routeName: 'mj_setting',
type: 'route'
}
]
},
{
label: '后台任务',
key: 'back_task',
type: 'action',
icon: 'backTask',
style: 'font-weight: bold;',
showCondition: 'always'
},
{
label: '工具箱',
key: 'toolbox',
routeName: 'toolbox',
type: 'route',
icon: 'toolBox'
}
]
// 图标映射
const iconMap = {
home: HomeOutline,
book: BookOutline,
plane: PaperPlaneOutline,
duplicate: DuplicateOutline,
grid: GridOutline,
api: APIIcon,
radio: RadioOutline,
settings: SettingsOutline,
backTask: BackTaskIcon,
toolBox: ToolBox,
}
// 渲染函数 - 根据数据源渲染菜单项
export function renderMenuLabel(item, options) {
// 修复:提供默认值
const opts = options || {}
const onBackTaskClick = opts.onBackTaskClick
switch (item.type) {
case 'route':
return () =>
h(
RouterLink,
{
to: { name: item.routeName },
class: item.className || ''
},
{ default: () => item.label }
)
case 'action':
if (item.key === 'back_task') {
return () =>
h(
'div',
{
onClick: () => {
if (onBackTaskClick) {
onBackTaskClick()
}
},
style: item.style || ''
},
{ default: () => item.label }
)
}
break
case 'group':
default:
return item.label
}
}
// 渲染图标函数
export function renderMenuIcon(option, options) {
// 修复:提供默认值
const opts = options || {}
const onBackTaskClick = opts.onBackTaskClick
// 特殊处理
if (option.key === 'sheep-man') return true
if (option.key === 'food') return null
// 后台任务特殊处理
if (option.key === 'back_task') {
return h(
NIcon,
{
onClick: () => {
if (onBackTaskClick) {
onBackTaskClick()
}
}
},
{ default: () => h(BackTaskIcon) }
)
}
// 根据数据源查找图标
const menuItem = findMenuItemByKey(option.key)
if (menuItem && menuItem.icon) {
const IconComponent = iconMap[menuItem.icon]
if (IconComponent) {
return h(NIcon, null, { default: () => h(IconComponent) })
}
}
return null
}
// 展开图标
export function expandIcon() {
return h(NIcon, null, { default: () => h(CaretDownOutline) })
}
// 辅助函数根据key查找菜单项
function findMenuItemByKey(key) {
function findInItems(items) {
for (const item of items) {
if (item.key === key) return item
if (item.children) {
const found = findInItems(item.children)
if (found) return found
}
}
return null
}
return findInItems(menuDataSource)
}
// 菜单过滤函数
function shouldShowMenuItem(item, conditions) {
// 修复:提供默认值
const conds = conditions || {}
if (!item.showCondition) return true
switch (item.showCondition) {
case 'showOriginal':
return conds.showOriginal
case 'always':
return true
default:
return true
}
}
// 转换数据源为菜单选项
function transformMenuItem(item, options) {
// 修复:提供默认值
const opts = options || {}
const onBackTaskClick = opts.onBackTaskClick
const conditions = opts.conditions || {}
const menuItem = {
key: item.key,
label: renderMenuLabel(item, { onBackTaskClick }),
children: undefined,
}
// 处理子菜单
if (item.children) {
menuItem.children = item.children
.filter(child => shouldShowMenuItem(child, conditions))
.map(child => transformMenuItem(child, opts))
}
return menuItem
}
// 主要的菜单选项生成函数
export function generateMenuOptions(showOriginal = true, onBackTaskClick) {
const conditions = { showOriginal }
const options = { onBackTaskClick, conditions }
return menuDataSource
.filter(item => shouldShowMenuItem(item, conditions))
.map(item => transformMenuItem(item, options))
}

View File

@ -0,0 +1,262 @@
import {
ImageOutline,
VideocamOutline,
MusicalNotesOutline,
DocumentTextOutline,
CodeSlashOutline,
ColorPaletteOutline,
CalculatorOutline,
GlobeOutline,
LockClosedOutline,
TimeOutline,
ArchiveOutline,
SettingsOutline,
CloudUploadOutline
} from '@vicons/ionicons5'
// 工具分类
export const categories = [
{ key: 'media', label: '媒体工具', color: '#2080f0' },
// { key: 'document', label: '文档处理', color: '#18a058' },
// { key: 'development', label: '开发工具', color: '#f0a020' },
// { key: 'design', label: '设计工具', color: '#d03050' },
// { key: 'utility', label: '实用工具', color: '#7c3aed' },
// { key: 'network', label: '网络工具', color: '#0ea5e9' },
// { key: 'security', label: '安全工具', color: '#dc2626' },
// { key: 'system', label: '系统工具', color: '#059669' }
]
// 工具数据
export const toolsData = [
// 媒体工具
{
id: 'image-converter',
name: 'LaiTool 图床',
description: "将图片上传到 LaiTool 图床,支持多种图片格式,获得可分享的链接",
category: 'media',
icon: CloudUploadOutline,
color: '#2080f0',
tags: ['图片', '转换', '格式'],
quickAccess: true,
action: {
type: 'route',
route: '/toolbox/image-upload'
}
},
{
id: 'image-compress',
name: '图片压缩助手',
description: "将图片进行压缩,支持多种图片格式,减小文件大小",
category: 'media',
icon: CloudUploadOutline,
color: '#2080f0',
tags: ['图片', '压缩', '格式'],
quickAccess: true,
action: {
type: 'route',
route: '/toolbox/image-compress'
}
},
// {
// id: 'image-converter',
// name: '图片格式转换',
// description: '支持多种图片格式之间的转换包括JPG、PNG、WebP、SVG等',
// category: 'media',
// icon: ImageOutline,
// color: '#2080f0',
// tags: ['图片', '转换', '格式'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/image-converter'
// }
// },
// {
// id: 'image-compressor',
// name: '图片压缩',
// description: '无损或有损压缩图片文件,减小文件大小',
// category: 'media',
// icon: ImageOutline,
// color: '#18a058',
// tags: ['图片', '压缩', '优化'],
// action: {
// type: 'route',
// route: '/toolbox/image-compressor'
// }
// },
// {
// id: 'video-converter',
// name: '视频格式转换',
// description: '转换视频文件格式支持MP4、AVI、MOV等主流格式',
// category: 'media',
// icon: VideocamOutline,
// color: '#f0a020',
// tags: ['视频', '转换', '格式'],
// action: {
// type: 'route',
// route: '/toolbox/video-converter'
// }
// },
// {
// id: 'audio-converter',
// name: '音频格式转换',
// description: '转换音频文件格式支持MP3、WAV、FLAC等格式',
// category: 'media',
// icon: MusicalNotesOutline,
// color: '#d03050',
// tags: ['音频', '转换', '格式'],
// action: {
// type: 'route',
// route: '/toolbox/audio-converter'
// }
// },
// // 文档工具
// {
// id: 'pdf-merger',
// name: 'PDF合并',
// description: '将多个PDF文件合并为一个文件',
// category: 'document',
// icon: DocumentTextOutline,
// color: '#18a058',
// tags: ['PDF', '合并', '文档'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/pdf-merger'
// }
// },
// {
// id: 'pdf-splitter',
// name: 'PDF分割',
// description: '将PDF文件按页数或书签分割成多个文件',
// category: 'document',
// icon: DocumentTextOutline,
// color: '#7c3aed',
// tags: ['PDF', '分割', '文档'],
// action: {
// type: 'route',
// route: '/toolbox/pdf-splitter'
// }
// },
// // 开发工具
// {
// id: 'json-formatter',
// name: 'JSON格式化',
// description: '格式化、验证和美化JSON数据',
// category: 'development',
// icon: CodeSlashOutline,
// color: '#f0a020',
// tags: ['JSON', '格式化', '开发'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/json-formatter'
// }
// },
// {
// id: 'base64-encoder',
// name: 'Base64编解码',
// description: '对文本或文件进行Base64编码和解码',
// category: 'development',
// icon: CodeSlashOutline,
// color: '#2080f0',
// tags: ['Base64', '编码', '解码'],
// action: {
// type: 'route',
// route: '/toolbox/base64-encoder'
// }
// },
// // 设计工具
// {
// id: 'color-picker',
// name: '颜色选择器',
// description: '选择颜色并获取各种格式的颜色值',
// category: 'design',
// icon: ColorPaletteOutline,
// color: '#d03050',
// tags: ['颜色', '设计', '取色'],
// action: {
// type: 'route',
// route: '/toolbox/color-picker'
// }
// },
// // 实用工具
// {
// id: 'calculator',
// name: '计算器',
// description: '多功能计算器,支持基础运算和科学计算',
// category: 'utility',
// icon: CalculatorOutline,
// color: '#7c3aed',
// tags: ['计算', '数学', '实用'],
// action: {
// type: 'function',
// handler: () => {
// alert('打开计算器')
// }
// }
// },
// {
// id: 'timestamp-converter',
// name: '时间戳转换',
// description: '时间戳与日期时间之间的相互转换',
// category: 'utility',
// icon: TimeOutline,
// color: '#0ea5e9',
// tags: ['时间', '转换', '时间戳'],
// action: {
// type: 'route',
// route: '/toolbox/timestamp-converter'
// }
// },
// // 网络工具
// {
// id: 'qr-generator',
// name: '二维码生成器',
// description: '生成各种类型的二维码',
// category: 'network',
// icon: GlobeOutline,
// color: '#0ea5e9',
// tags: ['二维码', '生成', '网络'],
// action: {
// type: 'route',
// route: '/toolbox/qr-generator'
// }
// },
// // 安全工具
// {
// id: 'password-generator',
// name: '密码生成器',
// description: '生成安全性高的随机密码',
// category: 'security',
// icon: LockClosedOutline,
// color: '#dc2626',
// tags: ['密码', '生成', '安全'],
// quickAccess: true,
// action: {
// type: 'route',
// route: '/toolbox/password-generator'
// }
// },
// // 系统工具
// {
// id: 'file-hasher',
// name: '文件哈希计算',
// description: '计算文件的MD5、SHA1、SHA256等哈希值',
// category: 'system',
// icon: ArchiveOutline,
// color: '#059669',
// tags: ['哈希', '文件', '校验'],
// action: {
// type: 'route',
// route: '/toolbox/file-hasher'
// }
// }
]

View File

@ -15,9 +15,8 @@
</template>
<script>
import { defineComponent, ref, onMounted, h, toRaw } from 'vue'
import { defineComponent, ref, h, toRaw } from 'vue'
import { NButton, useMessage, useDialog, NDivider, NCode, NSpin } from 'naive-ui'
import { DEFINE_STRING } from '../../../../define/define_string'
import ManageBadPrompt from '../Components/ManageBadPrompt.vue'
import GetWaterMask from '../Watermark/GetWaterMask.vue'

View File

@ -12,19 +12,13 @@
@expand="collapsed = false"
style="position: relative"
>
<n-menu
<HomeMenu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:render-icon="renderMenuIcon"
:expand-icon="expandIcon"
default-value="mainHome"
>
</n-menu>
:show-original="showOriginal"
@open-back-task="openBackTask"
/>
</n-layout-sider>
<n-layout-content content-style="padding: 5px 5px 5px 10px; height:100%">
<!-- <Setting></Setting> -->
<router-view></router-view>
</n-layout-content>
</n-layout>
@ -32,38 +26,22 @@
</template>
<script setup>
import { ref, h, onMounted, toRaw, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { ref, h, onMounted, toRaw } from 'vue'
import {
useDialog,
NMenu,
NSpace,
NLayout,
NLayoutSider,
NLayoutContent,
NIcon,
useNotification,
useMessage
} from 'naive-ui'
import {
CaretDownOutline,
HomeOutline,
PaperPlaneOutline,
SettingsOutline,
DuplicateOutline,
GridOutline,
RadioOutline,
BookOutline
} from '@vicons/ionicons5'
import { DEFINE_STRING } from '../../../../define/define_string'
import { MD5 } from 'crypto-js'
import InputDialogContent from '../Original/Components/InputDialogContent.vue'
import APIIcon from '../Icon/APIIcon.vue'
import BackTaskIcon from '../Icon/BackTaskIcon.vue'
import BackTask from '@/renderer/src/components/BackTask/BackTask.vue'
import { useSystemStore } from '../../../../stores/system'
import HomeMenu from './HomeMenu.vue'
let collapsed = ref(false)
let dialog = useDialog()
@ -71,39 +49,12 @@ let message = useMessage()
let notification = useNotification()
let key_down_ref = ref(null)
let showMenu = ref(true)
//
function renderMenuIcon(option) {
if (option.key === 'sheep-man') return true
if (option.key === 'food') return null
if (option.key == 'sdoriginal') return h(NIcon, null, { default: () => h(PaperPlaneOutline) })
if (option.key == 'setting') return h(NIcon, null, { default: () => h(SettingsOutline) })
if (option.key == 'gptCopywriting') return h(NIcon, null, { default: () => h(BookOutline) })
if (option.key == 'mainHome') return h(NIcon, null, { default: () => h(HomeOutline) })
if (option.key == 'book_management') return h(NIcon, null, { default: () => h(GridOutline) })
if (option.key == 'lai_api') return h(NIcon, null, { default: () => h(APIIcon) })
if (option.key == 'backward_matrix') return h(NIcon, null, { default: () => h(DuplicateOutline) })
if (option.key == 'back_task')
return h(
NIcon,
{
onClick: () => {
OpneBackTask()
}
},
{ default: () => h(BackTaskIcon) }
)
if (option.key == 'TTS_Services') return h(NIcon, null, { default: () => h(RadioOutline) })
}
let showOriginal = ref(true)
onMounted(async () => {
// ctrl + alt + l
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.altKey && e.key === 'l') {
//
//
//
let dialogWidth = 400
let dialogHeight = 150
dialog.create({
@ -120,9 +71,7 @@ onMounted(async () => {
style: `width : ${dialogWidth}px; min-height : ${dialogHeight}px`,
maskClosable: false,
onClose: async () => {
//
let password = toRaw(key_down_ref.value.data)
// md5
let md5_password = MD5(password + DEFINE_STRING.OPEN_DEV_TOOLS).toString()
window.api.OpenDevToolsPassword(md5_password, (value) => {
if (value.code == 0) {
@ -136,20 +85,22 @@ onMounted(async () => {
}
})
//
window.api.setEventListen([DEFINE_STRING.SHOW_MESSAGE_DIALOG], (value) => {
let message = value.message
let messageText = value.message
let type = 'success'
let title = '成功'
if (value.code == 0) {
type = 'error'
title = '成功但失败'
} else if (value.code == 2) {
;(type = 'warning'), (title = '警告')
type = 'warning'
title = '警告'
}
dialog.create({
type: type,
title: title,
content: message,
content: messageText,
showIcon: true,
style: `width : 400px;`,
maskClosable: false,
@ -158,7 +109,7 @@ onMounted(async () => {
})
window.api.setEventListen([DEFINE_STRING.SHOW_MAIN_NOTIFICATION], (value) => {
let message = value.message
let messageText = value.message
let type = 'success'
let title = '成功'
if (value.code == 0) {
@ -171,7 +122,7 @@ onMounted(async () => {
notification.create({
type: type,
title: title,
content: message,
content: messageText,
keepAliveOnHover: true,
duration: 2500
})
@ -193,323 +144,12 @@ onMounted(async () => {
window.api.getSettingDafultData(async (value) => {
window.config = value
if (!(window.config && window.config.showOriginal)) {
showMenu.value = false
showOriginal.value = false
}
})
})
function expandIcon(value) {
return h(NIcon, null, { default: () => h(CaretDownOutline) })
}
const menuOptions = computed(() => {
let me = [
{
label: () =>
h(
RouterLink,
{
to: {
name: 'mainHome'
},
class: 'router-link-a'
},
{
default: () => '首页'
}
),
key: 'mainHome'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'gptCopywriting'
},
class: 'router-link-a'
},
{
default: () => '文案处理'
}
),
key: 'gptCopywriting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'sdoriginal'
}
},
{
default: () => '原创生图(弃用)'
}
),
key: 'sdoriginal',
showMenu: showMenu.value
},
{
label: '超级矩阵',
key: 'backward_matrix',
children: [
{
label: () =>
h(
RouterLink,
{
to: {
name: 'getframe'
}
},
{ default: () => '抽帧' }
),
key: 'backward_frame'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'copywriting'
}
},
{ default: () => '文案洗稿' }
),
key: 'copywriting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'pushBackPrompt'
}
},
{ default: () => '反推提示词' }
),
key: 'push_back'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'regenerate'
}
},
{ default: () => '批次生成' }
),
key: 'regenerate'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'VideoGenerate'
}
},
{ default: () => '重绘/视频合成' }
),
key: 'VideoGenerate'
}
]
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'book_management'
}
},
{
default: () => '聚合推文'
}
),
key: 'book_management'
},
// {
// label: "",
// key: "clip_options",
// children: [
// {
// label: () => h(
// RouterLink,
// {
// to: {
// name: "align_draft",
// }
// },
// { default: () => "稿" }
// ),
// key: "align_draft"
// },
// {
// label: () => h(
// RouterLink,
// {
// to: {
// name: "add_draft",
// }
// },
// { default: () => "稿" }
// ),
// key: "add_draft"
// }
// ]
// },
{
label: () =>
h(
RouterLink,
{
to: {
name: 'lai_api'
}
},
{
default: () => 'API服务'
}
),
key: 'lai_api'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'TTS_Services'
}
},
{
default: () => '语音服务'
}
),
key: 'TTS_Services'
},
{
label: '设置',
key: 'setting',
children: [
{
label: () =>
h(
RouterLink,
{
to: {
name: 'global_setting'
}
},
{ default: () => '通用设置' }
),
key: 'global_setting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'clip_setting'
}
},
{ default: () => '剪映设置' }
),
key: 'clip_setting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'videogeneratesetting'
}
},
{ default: () => '生成视频设置' }
),
key: 'videogeneratesetting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'sd_setting'
},
class: 'sd_setting'
},
{ default: () => 'SD设置' }
),
key: 'sd_setting'
},
{
label: () =>
h(
RouterLink,
{
to: {
name: 'mj_setting'
}
},
{ default: () => 'MJ设置' }
),
key: 'mj_setting'
}
// {
// label: () =>
// h(
// RouterLink,
// {
// to: {
// name: 'video_setting'
// }
// },
// { default: () => '' }
// ),
// key: 'video_setting'
// }
]
},
{
label: () =>
h(
'div',
{
onClick: () => {
OpneBackTask()
},
style: 'font-weight: bold;'
},
{
default: () => '后台任务'
}
),
key: 'back_task',
showMenu: true
}
]
if (window.config && window.config.showOriginal) {
return me
} else {
return me.filter((item) => item.showMenu || item.showMenu == undefined)
}
})
function OpneBackTask() {
function openBackTask() {
let dialogWidth = window.innerWidth * 0.9
if (dialogWidth < 800) dialogWidth = 800
let dialogHeight = window.innerHeight * 0.95

View File

@ -0,0 +1,45 @@
<template>
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
:render-icon="renderIcon"
:expand-icon="expandIcon"
default-value="mainHome"
/>
</template>
<script setup>
import { computed } from 'vue'
import { generateMenuOptions, renderMenuIcon, expandIcon } from '@/renderer/src/common/homeMenu'
// Props
const props = defineProps({
collapsed: {
type: Boolean,
default: false
},
showOriginal: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits(['openBackTask'])
//
const menuOptions = computed(() => {
return generateMenuOptions(props.showOriginal, () => {
emit('openBackTask')
})
})
//
function renderIcon(option) {
return renderMenuIcon(option, () => {
emit('openBackTask')
})
}
</script>

View File

@ -1,77 +0,0 @@
<template>
<div style="width: 100%; height: 100%">
<div style="font-size: 15px; display: flex; justify-content: center; width: 100%; height: 100%">
<div id="showmessage"></div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useMessage, useNotification } from 'naive-ui'
import { version } from '../../../../../package.json'
import { isEmpty } from 'lodash'
let message = useMessage()
const notification = useNotification()
async function GetRemoteSystemInformation() {
let res = await window.system.GetRemoteSystemInformation()
if (res.code == 0) {
message.error(res.message)
return
}
if (!isEmpty(res.data.remoteHomePage)) {
const showMessageDiv = document.getElementById('showmessage')
const remoteHomePage = res.data.remoteHomePage
if (remoteHomePage.startsWith('http://') || remoteHomePage.startsWith('https://')) {
const iframe = document.createElement('iframe')
iframe.src = remoteHomePage
iframe.style.width = '100%'
iframe.style.border = 'none'
iframe.style.padding = '0'
iframe.style.height = '98vh' // Adjust the height as needed
showMessageDiv.innerHTML = ''
showMessageDiv.style.width = '100%'
showMessageDiv.appendChild(iframe)
} else {
showMessageDiv.innerHTML = remoteHomePage
}
}
//
if (res.data.remoteVersion > version) {
notification.warning({
title: '更新提醒!',
content: `当前版本为 ${version} ,最新版本为 ${res.data.remoteVersion} ,请及时更新!`,
keepAliveOnHover: true
})
}
//
if (res.data.remoteVersion > version && !isEmpty(res.data.remoteUpdateContent)) {
notification.info({
title: '更新内容!',
content: res.data.remoteUpdateContent,
keepAliveOnHover: true
})
}
}
onMounted(async () => {
//
await GetRemoteSystemInformation()
// await window.api.GetShowMessage((value) => {
// if (value.code == 0) {
// return
// }
// let parser = new DOMParser()
// let html = parser.parseFromString(value.data, 'text/html')
// let div = document.getElementById('showmessage')
// div.innerHTML = value.data.shareInfo.note_list[0].html_content
// })
})
</script>

View File

@ -0,0 +1,16 @@
<template>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24"
>
<g fill="none">
<path
d="M7 6.253V7H4.25A2.25 2.25 0 0 0 2 9.25v8.497a2.25 2.25 0 0 0 2.25 2.25h15.5a2.25 2.25 0 0 0 2.25-2.25V9.25A2.25 2.25 0 0 0 19.75 7H17v-.747a2.25 2.25 0 0 0-2.25-2.25h-5.5A2.25 2.25 0 0 0 7 6.253zm2.25-.75h5.5a.75.75 0 0 1 .75.75V7h-7v-.747a.75.75 0 0 1 .75-.75zm-2.25 3h1.5V8.5h7v.003H17V8.5h2.75a.75.75 0 0 1 .75.75v2.25h-3v-.75a.75.75 0 0 0-1.5 0v.75H8v-.75a.75.75 0 0 0-1.5 0v.75h-3V9.25a.75.75 0 0 1 .75-.75H7v.003zM16 13v1.25a.75.75 0 0 0 1.5 0V13h3v4.747a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V13h3v1.25a.75.75 0 0 0 1.5 0V13h8z"
fill="currentColor"
></path>
</g>
</svg>
</div>
</template>

View File

@ -0,0 +1,632 @@
<template>
<div class="image-compress-home">
<!-- 页面标题 -->
<div class="page-header">
<h1>图片压缩工具</h1>
<p class="subtitle">快速压缩图片本地处理安全可靠</p>
</div>
<!-- 主要内容 -->
<div class="content">
<!-- 左侧面板 -->
<div class="left-panel">
<!-- 上传区域 -->
<n-upload
ref="uploadRef"
:max="1"
accept="image/*"
:default-upload="false"
@change="handleFileSelect"
:show-file-list="false"
>
<n-upload-dragger class="upload-section">
<div class="upload-content">
<div class="upload-icon">📁</div>
<h3>选择图片</h3>
<p>支持 JPGPNGWebP 格式</p>
<div class="or-text">点击选择或拖拽图片到此处</div>
</div>
</n-upload-dragger>
</n-upload>
<!-- 控制面板 -->
<n-card class="controls" size="small">
<!-- 尺寸设置 -->
<div class="control-group">
<h4>尺寸设置</h4>
<div class="slider-container">
<n-text>最大宽度: {{ widthValue }}px</n-text>
<n-slider
v-model:value="widthValue"
:min="100"
:max="2000"
:step="50"
@update:value="handleSliderChange"
/>
</div>
<div class="slider-container">
<n-text>最大高度: {{ heightValue }}px</n-text>
<n-slider
v-model:value="heightValue"
:min="100"
:max="2000"
:step="50"
@update:value="handleSliderChange"
/>
</div>
</div>
<!-- 压缩设置 -->
<div class="control-group">
<h4>压缩设置</h4>
<div class="slider-container">
<n-text>图片质量: {{ qualityValue }}%</n-text>
<n-slider
v-model:value="qualityValue"
:min="10"
:max="100"
:step="1"
@update:value="handleSliderChange"
/>
</div>
<div class="slider-container">
<n-text>输出格式</n-text>
<n-select
v-model:value="outputFormat"
:options="formatOptions"
@update:value="handleFormatChange"
/>
</div>
</div>
</n-card>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<div class="preview-section">
<!-- 原始图片预览 -->
<n-card class="preview-container" size="small">
<template #header>
<h4>原始图片</h4>
</template>
<div class="image-container">
<img v-if="originalImage" :src="originalImage" alt="原始图片" />
<div v-else class="placeholder">未选择图片</div>
</div>
</n-card>
<!-- 压缩后图片预览 -->
<n-card class="preview-container" size="small">
<template #header>
<h4>压缩后图片</h4>
</template>
<div class="image-container">
<img v-if="compressedImage" :src="compressedImage" alt="压缩后图片" />
<div v-else class="placeholder">
{{ isCompressing ? '压缩中...' : '等待压缩...' }}
</div>
</div>
</n-card>
<!-- 压缩信息 -->
<n-card class="info-card" size="small">
<template #header>
<h4>压缩信息</h4>
</template>
<div class="info-items">
<div class="info-item">
<span>原始大小:</span>
<span>{{ originalSizeText }}</span>
</div>
<div class="info-item">
<span>压缩后大小:</span>
<span>{{ compressedSizeText }}</span>
</div>
<div class="info-item">
<span>尺寸减少:</span>
<span>{{ sizeReductionText }}</span>
</div>
<div class="info-item">
<span>压缩比率:</span>
<span>{{ compressionRatioText }}</span>
</div>
</div>
<div class="size-reduction" v-if="compressionInfo.ratio > 0">
<n-text strong>
减少 {{ compressionInfo.ratio }}%节省 {{ compressionInfo.saved }}
</n-text>
</div>
<!-- 按钮组 -->
<div class="button-group">
<n-button
type="primary"
class="download-btn"
:disabled="!compressedImage"
@click="downloadCompressedImage"
size="large"
>
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载压缩图片
</n-button>
<n-button
type="default"
class="clear-btn"
:disabled="!originalImage && !compressedImage"
@click="clearAll"
size="large"
>
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
清空重置
</n-button>
</div>
</n-card>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import {
NUpload,
NUploadDragger,
NButton,
NCard,
NSlider,
NSelect,
NText,
NIcon,
useMessage
} from 'naive-ui'
import { DownloadOutline, RefreshOutline } from '@vicons/ionicons5'
//
const uploadRef = ref(null)
const originalImage = ref('')
const compressedImage = ref('')
const originalFile = ref(null)
const isCompressing = ref(false)
//
const widthValue = ref(800)
const heightValue = ref(600)
const qualityValue = ref(80)
const outputFormat = ref('image/jpeg')
//
const originalSize = ref(0)
const compressedSize = ref(0)
const message = useMessage()
//
const formatOptions = [
{ label: 'JPG', value: 'image/jpeg' },
{ label: 'PNG', value: 'image/png' },
{ label: 'WebP', value: 'image/webp' }
]
//
const originalSizeText = computed(() => {
return originalSize.value ? formatFileSize(originalSize.value) : '-'
})
const compressedSizeText = computed(() => {
return compressedSize.value ? formatFileSize(compressedSize.value) : '-'
})
const sizeReductionText = computed(() => {
if (!originalSize.value || !compressedSize.value) return '-'
const reduction = originalSize.value - compressedSize.value
return formatFileSize(reduction)
})
const compressionRatioText = computed(() => {
if (!originalSize.value || !compressedSize.value) return '-'
const ratio = ((originalSize.value - compressedSize.value) / originalSize.value * 100).toFixed(1)
return `${ratio}%`
})
const compressionInfo = computed(() => {
if (!originalSize.value || !compressedSize.value) {
return { ratio: 0, saved: '' }
}
const reduction = originalSize.value - compressedSize.value
const ratio = ((reduction / originalSize.value) * 100).toFixed(1)
const saved = formatFileSize(reduction)
return { ratio, saved }
})
//
watch([widthValue, heightValue, qualityValue], () => {
if (originalImage.value && !isCompressing.value) {
compressImage()
}
}, { deep: true })
//
function selectFile() {
uploadRef.value?.openFileDialog()
}
function handleFileSelect({ fileList }) {
if (fileList.length > 0) {
const file = fileList[0].file
handleFile(file)
}
}
function handleFile(file) {
if (!file.type.match('image.*')) {
message.error('请选择图片文件 (JPG, PNG, WebP)')
return
}
originalFile.value = file
originalSize.value = file.size
//
const reader = new FileReader()
reader.onload = (e) => {
originalImage.value = e.target.result
//
compressImage()
}
reader.readAsDataURL(file)
}
function handleSliderChange() {
// watch
}
function handleFormatChange() {
if (originalImage.value && !isCompressing.value) {
compressImage()
}
}
async function compressImage() {
if (!originalImage.value) return
isCompressing.value = true
compressedImage.value = ''
try {
// UI
await new Promise(resolve => setTimeout(resolve, 100))
const img = new Image()
img.onload = () => {
try {
//
let width = img.width
let height = img.height
if (width > widthValue.value) {
height = (widthValue.value / width) * height
width = widthValue.value
}
if (height > heightValue.value) {
width = (heightValue.value / height) * width
height = heightValue.value
}
// Canvas
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
// PNG
if (outputFormat.value === 'image/png') {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
}
ctx.drawImage(img, 0, 0, width, height)
// URL
let dataURL
const quality = qualityValue.value / 100
if (outputFormat.value === 'image/png') {
dataURL = canvas.toDataURL('image/png')
} else if (outputFormat.value === 'image/webp') {
dataURL = canvas.toDataURL('image/webp', quality)
} else {
dataURL = canvas.toDataURL('image/jpeg', quality)
}
//
const compressedFileSize = Math.round(dataURL.length * 0.75)
compressedSize.value = compressedFileSize
//
compressedImage.value = dataURL
isCompressing.value = false
} catch (error) {
console.error('压缩错误:', error)
message.error('压缩出错,请重试')
isCompressing.value = false
}
}
img.onerror = () => {
message.error('图片加载失败')
isCompressing.value = false
}
img.src = originalImage.value
} catch (error) {
console.error('压缩过程出错:', error)
message.error('压缩过程出错')
isCompressing.value = false
}
}
function downloadCompressedImage() {
if (!compressedImage.value || !originalFile.value) return
try {
const link = document.createElement('a')
link.href = compressedImage.value
link.download = getOutputFileName(originalFile.value.name, outputFormat.value)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('图片下载成功')
} catch (error) {
console.error('下载失败:', error)
message.error('下载失败,请重试')
}
}
//
function clearAll() {
//
originalImage.value = ''
compressedImage.value = ''
originalFile.value = null
isCompressing.value = false
//
originalSize.value = 0
compressedSize.value = 0
//
widthValue.value = 800
heightValue.value = 600
qualityValue.value = 80
outputFormat.value = 'image/jpeg'
//
if (uploadRef.value) {
uploadRef.value.clear()
}
message.success('已清空所有数据')
}
//
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
else return (bytes / 1048576).toFixed(1) + ' MB'
}
function getOutputFileName(originalName, format) {
const ext = format.split('/')[1]
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '')
return `${nameWithoutExt}_compressed.${ext}`
}
</script>
<style scoped>
.image-compress-home {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
margin: 0 0 8px 0;
font-weight: 600;
}
.subtitle {
font-size: 16px;
color: var(--n-text-color-depth-2);
margin: 0;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
.image-compress-home {
padding: 16px;
}
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-section {
padding: 32px;
text-align: center;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.upload-icon {
font-size: 48px;
margin-bottom: 8px;
}
.upload-content h3 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.upload-content p {
margin: 0;
color: var(--n-text-color-depth-2);
font-size: 14px;
}
.or-text {
color: var(--n-text-color-depth-3);
font-size: 13px;
}
.controls {
padding: 20px;
}
.control-group {
margin-bottom: 20px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group h4 {
font-size: 16px;
margin: 0 0 16px 0;
font-weight: 500;
}
.slider-container {
margin-bottom: 16px;
}
.slider-container:last-child {
margin-bottom: 0;
}
.slider-container .n-text {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-container h4,
.info-card h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.image-container {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: var(--n-card-color);
border: 1px solid var(--n-border-color);
border-radius: 6px;
overflow: hidden;
}
.image-container img {
max-width: 100%;
max-height: 250px;
object-fit: contain;
}
.placeholder {
color: var(--n-text-color-depth-3);
font-size: 14px;
}
.info-items {
margin-bottom: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
}
.info-item:not(:last-child) {
border-bottom: 1px solid var(--n-border-color);
}
.size-reduction {
text-align: center;
padding: 12px;
background: var(--n-primary-color-suppl);
border-radius: 6px;
margin-bottom: 16px;
}
.button-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.download-btn,
.clear-btn {
width: 100%;
}
@media (max-width: 768px) {
.button-group {
gap: 12px;
}
}
</style>

View File

@ -0,0 +1,403 @@
<template>
<div class="image-display">
<!-- 标题和操作栏 -->
<div class="display-header">
<h2>已上传图片 ({{ total ?? 0 }})</h2>
<n-space>
<n-button @click="refreshList" type="primary">
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
刷新数据
</n-button>
<!-- 修改为下拉菜单形式的导出 -->
<n-dropdown
:options="exportOptions"
:disabled="imageList.length === 0"
@select="handleExportSelect"
>
<n-button type="primary">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
导出数据
<template #icon-right>
<n-icon><ChevronDownOutline /></n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</div>
<!-- 数据表格 -->
<n-data-table
ref="tableRef"
:columns="columns"
:data="imageList"
:pagination="paginationReactive"
:loading="tableLoading"
striped
size="small"
:remote="true"
:scroll-x="1200"
flex-height
style="height: 500px"
/>
</div>
</template>
<script setup>
import { ref, reactive, h } from 'vue'
import {
NDataTable,
NButton,
NSpace,
NImage,
NTag,
NIcon,
NDropdown, //
useMessage
} from 'naive-ui'
import {
RefreshOutline,
DownloadOutline,
CopyOutline,
ChevronDownOutline //
} from '@vicons/ionicons5'
// Emits
const emit = defineEmits(['paginationChange'])
//
const tableRef = ref(null)
const tableLoading = ref(true)
const total = ref(0)
const imageList = ref([])
const message = useMessage()
//
const exportOptions = [
{
label: '导出为 JSON',
key: 'json',
icon: () => h(NIcon, () => h(DownloadOutline))
},
{
label: '导出为 CSV',
key: 'csv',
icon: () => h(NIcon, () => h(DownloadOutline))
}
]
// 使 reactive ref
const paginationReactive = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
showQuickJumper: true,
pageSizes: [10, 20, 50, 100],
onChange: (page) => {
paginationReactive.page = page
refreshList()
},
prefix({ itemCount }) {
return `${itemCount}`
},
onUpdatePageSize: (pageSize) => {
paginationReactive.pageSize = pageSize
paginationReactive.page = 1
refreshList()
}
})
//
const columns = [
{
title: '序号',
key: 'index',
width: 60,
render: (row, index) => {
return (paginationReactive.page - 1) * paginationReactive.pageSize + index + 1
}
},
{
title: '预览',
key: 'preview',
width: 80,
render: (row) => {
return h(NImage, {
width: 50,
height: 50,
src: row.url,
objectFit: 'cover',
style: 'border-radius: 4px; cursor: pointer'
})
}
},
{
title: '文件地址',
key: 'url',
width: 300,
render: (row) => {
return h('div', { style: 'display: flex; align-items: center; gap: 8px' }, [
h(
'a',
{
href: 'javascript:void(0)',
style:
'color: var(--n-color-target); text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap',
title: row.url
},
row.url
),
h(
NButton,
{
size: 'tiny',
quaternary: true,
circle: true,
onClick: () => copyBase64(row)
},
{ icon: () => h(NIcon, () => h(CopyOutline)) }
)
])
}
},
{
title: '文件大小',
key: 'fileSize',
width: 100,
render: (row) => formatFileSize(row.fileSize)
},
{
title: '文件类型',
key: 'type',
width: 100,
render: (row) => {
return h(NTag, { size: 'small', type: 'info' }, () => row.contentType)
}
},
{
title: '上传时间',
key: 'uploadTime',
width: 160,
render: (row) => formatDate(row.uploadTime)
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (row) => {
return h(NSpace, { size: 'small' }, () => [
h(
NButton,
{
size: 'small',
type: 'info',
quaternary: true,
onClick: () => copyBase64(row)
},
{ default: () => '复制', icon: () => h(NIcon, () => h(CopyOutline)) }
)
])
}
}
]
async function copyBase64(image) {
try {
await navigator.clipboard.writeText(image.url)
message.success('文件地址已复制到剪贴板')
} catch (error) {
message.error('复制失败,请手动复制')
}
}
function refreshList() {
tableLoading.value = true
emit('paginationChange', {
page: paginationReactive.page,
pageSize: paginationReactive.pageSize
})
}
//
function handleExportSelect(key) {
if (key === 'json') {
exportAsJson()
} else if (key === 'csv') {
exportAsCsv()
}
}
// JSON
function exportAsJson() {
try {
const data = imageList.value
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
downloadFile(blob, `images_export_${getTimestamp()}.json`)
message.success('JSON 数据导出成功')
} catch (error) {
console.error('JSON 导出失败:', error)
message.error('JSON 导出失败,请重试')
}
}
// CSV
function exportAsCsv() {
try {
const data = imageList.value
if (data.length === 0) {
message.warning('没有数据可导出')
return
}
// CSV
const headers = ['序号', '文件名', '文件地址', '文件大小', '文件类型', '上传时间']
//
const csvRows = [
headers.join(','), //
...data.map((item, index) =>
[
index + 1, //
`"${item.fileName || ''}"`, //
`"${item.url || ''}"`, //
`"${formatFileSize(item.fileSize || 0)}"`, //
`"${item.contentType || ''}"`, //
`"${formatDate(item.uploadTime || new Date())}"` //
].join(',')
)
]
const csvContent = csvRows.join('\n')
// BOM
const bom = '\uFEFF'
const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8' })
downloadFile(blob, `images_export_${getTimestamp()}.csv`)
message.success('CSV 数据导出成功')
} catch (error) {
console.error('CSV 导出失败:', error)
message.error('CSV 导出失败,请重试')
}
}
//
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
//
function getTimestamp() {
const now = new Date()
return (
now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
'_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0')
)
}
//
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function formatDate(date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date(date))
}
function reloadImageData(value) {
console.log('重新加载图片数据', value)
//
total.value = value.total || 0
imageList.value = value.collection ?? []
tableLoading.value = false
//
paginationReactive.page = value.current || 1
paginationReactive.itemCount = value.total || 0
// pageCount
// paginationReactive.value.pageCount = Math.ceil(value.total / 10) //
console.log('分页调试信息:', {
total: value.total,
pageSize: paginationReactive.pageSize,
itemCount: paginationReactive.itemCount,
currentPage: paginationReactive.page
})
}
//
defineExpose({ reloadImageData })
</script>
<style scoped>
.image-display {
padding: 20px;
}
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.display-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.preview-content {
text-align: center;
}
.preview-info {
margin-top: 20px;
text-align: left;
}
@media (max-width: 768px) {
.image-display {
padding: 16px;
}
.display-header {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="image-upload-home">
<!-- 页面标题 -->
<div class="page-header">
<h1>图片上传工具</h1>
<p>上传图片到LaiTool图床获取图片链接</p>
</div>
<!-- 上传组件 -->
<ImageUploader @upload-success="handleUploadSuccess" />
<!-- 分割线 -->
<n-divider />
<!-- 显示组件 -->
<ImageDisplay
ref="imageDisplayRef"
@vue:mounted="loadImageList"
:image-list="imageList"
@pagination-change="paginationChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { NDivider, useMessage } from 'naive-ui'
import ImageUploader from './ImageUploader.vue'
import ImageDisplay from './ImageDisplay.vue'
import { define } from '@/define/define'
import { useSystemStore } from '@/stores/system'
//
const imageList = ref([])
const message = useMessage()
const imageDisplayRef = ref(null)
const systemStore = useSystemStore()
onMounted(() => {})
//
async function loadImageList({ page = 1, pageSize = 10 }) {
let res = await window.axios.get(
define.lms +
`/lms/FileUpload/GetFilesByMachineId/${systemStore.machineId}?page=${page}&pageSize=${pageSize}`
)
imageDisplayRef.value.reloadImageData(res.data.data)
}
//
async function handleUploadSuccess() {
//
await loadImageList()
}
async function paginationChange(value) {
//
await loadImageList(value)
}
</script>
<style scoped>
.image-upload-home {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
min-width: 600px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: var(--n-text-color);
}
.page-header p {
margin: 0;
color: var(--n-text-color-depth-2);
font-size: 14px;
}
@media (max-width: 768px) {
.image-upload-home {
padding: 16px;
}
}
</style>

View File

@ -0,0 +1,470 @@
<template>
<div class="image-uploader">
<!-- 上传区域 -->
<n-upload
ref="uploadRef"
:max="1"
accept="image/*"
:disabled="uploading"
:default-upload="false"
@change="handleFileChange"
:show-file-list="false"
>
<n-upload-dragger>
<div class="upload-content">
<n-icon size="48" :depth="uploading ? 2 : 3">
<SyncOutline v-if="uploading" />
<CloudUploadOutline v-else />
</n-icon>
<n-text style="font-size: 16px; margin-top: 8px">
{{ uploading ? '正在处理中...' : '点击或拖拽图片到此区域上传' }}
</n-text>
<n-text depth="3" style="font-size: 12px; margin-top: 4px">
{{
uploading
? '请等待当前文件处理完成'
: '支持 JPG、PNG、GIF、WebP 格式,单个文件不超过 5MB'
}}
</n-text>
</div>
</n-upload-dragger>
</n-upload>
<!-- 操作按钮 -->
<div class="upload-actions">
<n-space>
<n-button
@click="startUpload"
:disabled="!selectedFile || uploading"
size="medium"
type="success"
>
<template #icon>
<n-icon><CloudUploadOutline /></n-icon>
</template>
开始上传
</n-button>
<n-button @click="clearFiles" size="medium">
<template #icon>
<n-icon><StopOutline /></n-icon>
</template>
清空选择
</n-button>
</n-space>
</div>
<!-- 选中的文件信息 -->
<div class="selected-file" v-if="selectedFile">
<n-card size="small" style="margin-top: 16px">
<div class="file-info">
<div class="file-details">
<n-text strong>{{ selectedFile.name }}</n-text>
<n-text depth="2" style="font-size: 12px">
{{ formatFileSize(selectedFile.size) }} | {{ selectedFile.type }}
</n-text>
<!-- 简单的上传状态提示 -->
<div v-if="uploading" class="upload-status-text">
<n-text depth="3" style="font-size: 11px; color: var(--n-color-warning)">
{{ statusText }}
</n-text>
</div>
</div>
<div class="file-preview" v-if="previewUrl">
<!-- 预览图片容器 -->
<div class="preview-container">
<n-image
:render-toolbar="renderToolbar"
:src="previewUrl"
alt="预览"
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px"
:class="{ 'uploading-blur': uploading }"
/>
<!-- 上传时的加载动画覆盖层 -->
<div v-if="uploading" class="loading-overlay">
<n-spin size="small" />
</div>
</div>
</div>
</div>
</n-card>
</div>
<!-- 上传提示 -->
<div class="upload-tips">
<n-alert type="info" style="margin-top: 16px">
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
<div>
<p style="margin: 0 0 8px 0"><strong>上传说明</strong></p>
<ul style="margin: 0; padding-left: 16px">
<li>选择文件后点击"开始上传"按钮进行上传</li>
<li>每次只能上传一个文件并且上传的文件会留存在服务器介意请勿用</li>
<li>支持的格式JPGPNGJPEGWebP</li>
<li>文件大小限制最大 5MB</li>
<li>单日上传次数限制5</li>
</ul>
</div>
</n-alert>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import {
NUpload,
NUploadDragger,
NIcon,
NText,
NButton,
NSpace,
NProgress,
NAlert,
NCard,
useMessage
} from 'naive-ui'
import {
CloudUploadOutline,
FolderOpenOutline,
StopOutline,
SyncOutline,
InformationCircleOutline
} from '@vicons/ionicons5'
import { TimeDelay } from '@/define/Tools/time'
import { define } from '@/define/define'
import { useSystemStore } from '@/stores/system'
import { isEmpty } from 'lodash'
// Emits
const emit = defineEmits(['uploadSuccess', 'uploadError'])
//
const uploadRef = ref(null)
const uploading = ref(false)
const statusText = ref('')
const selectedFile = ref(null)
const previewUrl = ref('')
const systemStore = useSystemStore()
const message = useMessage()
//
const renderToolbar = ({ nodes }) => {
return [nodes.close]
}
//
function handleFileChange({ fileList }) {
if (fileList.length > 0) {
const file = fileList[0].file
if (validateFile(file)) {
selectedFile.value = file
// URL
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = URL.createObjectURL(file)
message.success(`已选择文件: ${file.name}`)
}
} else {
selectedFile.value = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
}
}
//
async function startUpload() {
if (!selectedFile.value) {
message.warning('请先选择文件')
return
}
if (uploading.value) {
message.warning('请等待当前文件上传完成')
return
}
await handleUpload(selectedFile.value)
}
async function handleUpload(file) {
const fileName = file.name
//
uploading.value = true
statusText.value = '开始上传文件...'
await TimeDelay(500)
try {
// 2:
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
statusText.value = '文件超出限制,请压缩后上传!!'
return
}
statusText.value = '开始处理图片文件...'
const imageData = await processImage(file)
// data:image/jpeg;base64,
if (imageData.base64.startsWith('data:')) {
imageData.base64 = imageData.base64.split(',')[1]
}
await TimeDelay(500)
// 3:
statusText.value = '图片处理完毕,开始上传文件...'
//
if (isEmpty(systemStore.machineId)) {
message.error('未找到机器ID请重启软件后重试')
return
}
let res = await window.axios.post(
define.lms + `/lms/FileUpload/FileUpload/${systemStore.machineId}`,
{
file: imageData.base64,
fileName: fileName,
contentType: file.type
}
)
await TimeDelay(500)
if (!res.success) {
//
message.error(`上传失败: ${res.message || '未知错误'}`)
return
}
if (res.data && res.data.code == 1) {
const uploadedImage = res.data
//
emit('uploadSuccess', uploadedImage)
//
clearFiles()
} else {
//
message.error(`上传失败: ${res.data.message || '未知错误'}`)
return
}
} catch (error) {
message.error(`上传失败: ${error.message || '未知错误'}`)
} finally {
// /
setTimeout(() => {
uploading.value = false
statusText.value = ''
}, 1000)
}
}
//
function validateFile(file) {
console.log('Validating file:', file.name, file.type, file.size)
//
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
message.error('只支持 JPG、PNG、GIF、WebP 格式的图片')
return false
}
// (10MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
message.error('文件大小不能超过 5MB')
return false
}
return true
}
async function processImage(file) {
console.log('Processing image:', file.name)
return new Promise((resolve, reject) => {
const reader = new FileReader()
const img = new Image()
reader.onload = (e) => {
img.onload = () => {
console.log('Image loaded, dimensions:', img.width, 'x', img.height)
resolve({
base64: e.target.result,
width: img.width,
height: img.height
})
}
img.onerror = () => reject(new Error('无法加载图片'))
img.src = e.target.result
}
reader.onerror = () => reject(new Error('无法读取文件'))
reader.readAsDataURL(file)
})
}
function clearFiles() {
console.log('Clear files clicked')
selectedFile.value = null
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
// upload
if (uploadRef.value) {
uploadRef.value.clear()
}
}
//
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.image-uploader {
padding: 20px;
}
.upload-content {
text-align: center;
padding: 40px 20px;
transition: all 0.3s ease;
}
.upload-status {
margin-top: 16px;
padding: 20px;
background: var(--n-card-color);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
.status-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.status-actions {
margin-top: 12px;
text-align: right;
}
.upload-actions {
margin-top: 16px;
text-align: center;
}
.selected-file {
margin-top: 16px;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.upload-tips {
margin-top: 16px;
}
.upload-tips ul li {
margin-bottom: 4px;
font-size: 13px;
}
@media (max-width: 768px) {
.image-uploader {
padding: 16px;
}
.upload-actions {
text-align: left;
}
.status-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
.preview-container {
position: relative;
display: inline-block;
}
.uploading-blur {
filter: blur(1px);
opacity: 0.7;
transition: all 0.3s ease;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 4px;
backdrop-filter: blur(2px);
}
.upload-progress-info {
margin-top: 8px;
}
.upload-status-badge {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: flex-start; /* 改为 flex-start 以便对齐 */
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.loading-overlay {
background: rgba(0, 0, 0, 0.6);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.file-info {
flex-direction: column;
gap: 12px;
align-items: center;
}
.upload-status-badge {
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,261 @@
<template>
<div class="toolbox-home">
<!-- 顶部搜索和筛选区域 -->
<div class="header-section">
<div class="search-bar">
<n-input
v-model:value="searchKeyword"
placeholder="搜索工具..."
clearable
size="large"
style="max-width: 400px"
>
<template #prefix>
<n-icon>
<SearchOutline />
</n-icon>
</template>
</n-input>
</div>
<div class="filter-bar">
<n-space>
<n-select
v-model:value="selectedCategory"
:options="categoryOptions"
placeholder="选择分类"
clearable
style="width: 150px"
/>
<n-select
v-model:value="selectedTag"
:options="tagOptions"
placeholder="选择标签"
clearable
style="width: 120px"
/>
<n-button @click="resetFilters" quaternary>
<template #icon>
<n-icon><RefreshOutline /></n-icon>
</template>
重置
</n-button>
</n-space>
</div>
</div>
<!-- 工具分类标签页 -->
<div class="category-tabs">
<n-tabs v-model:value="activeCategory" type="line" animated>
<n-tab-pane name="all" tab="全部">
<ToolGrid :tools="filteredTools" @tool-click="handleToolClick" />
</n-tab-pane>
<n-tab-pane
v-for="category in categories"
:key="category.key"
:name="category.key"
:tab="category.label"
>
<ToolGrid :tools="getToolsByCategory(category.key)" @tool-click="handleToolClick" />
</n-tab-pane>
</n-tabs>
</div>
<!-- 统计信息 -->
<div class="stats-section">
<n-space>
<n-statistic label="工具总数" :value="totalTools" />
<n-statistic label="分类数量" :value="categories.length" />
</n-space>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import {
NInput,
NSelect,
NButton,
NSpace,
NTabs,
NTabPane,
NDivider,
NTag,
NStatistic,
NIcon
} from 'naive-ui'
import { SearchOutline, RefreshOutline, FlashOutline, TimeOutline } from '@vicons/ionicons5'
import ToolGrid from './ToolGrid.vue'
import { toolsData, categories } from '@/renderer/src/common/toolData'
import { useRouter } from 'vue-router'
//
const searchKeyword = ref('')
const selectedCategory = ref(null)
const selectedTag = ref(null)
const activeCategory = ref('all')
const message = useMessage()
let router = useRouter()
//
const categoryOptions = computed(() =>
categories.map((cat) => ({ label: cat.label, value: cat.key }))
)
const tagOptions = computed(() => {
const allTags = new Set()
toolsData.forEach((tool) => {
tool.tags?.forEach((tag) => allTags.add(tag))
})
return Array.from(allTags).map((tag) => ({ label: tag, value: tag }))
})
const filteredTools = computed(() => {
let tools = toolsData
//
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
tools = tools.filter(
(tool) =>
tool.name.toLowerCase().includes(keyword) ||
tool.description.toLowerCase().includes(keyword) ||
tool.tags?.some((tag) => tag.toLowerCase().includes(keyword))
)
}
//
if (selectedCategory.value) {
tools = tools.filter((tool) => tool.category === selectedCategory.value)
}
//
if (selectedTag.value) {
tools = tools.filter((tool) => tool.tags?.includes(selectedTag.value))
}
//
if (activeCategory.value !== 'all') {
tools = tools.filter((tool) => tool.category === activeCategory.value)
}
return tools
})
const totalTools = computed(() => toolsData.length)
//
function getToolsByCategory(categoryKey) {
return toolsData.filter((tool) => tool.category === categoryKey)
}
function handleToolClick(tool) {
//
executeToolAction(tool)
}
function executeToolAction(tool) {
//
debugger
switch (tool.action?.type) {
case 'route':
//
if (!tool.action.route) {
message.error('路由路径未配置')
return
}
router.push(tool.action.route)
break
case 'function':
//
console.log('执行函数:', tool.action.handler)
if (typeof tool.action.handler === 'function') {
tool.action.handler()
}
break
case 'external':
if (!tool.action.url) {
message.error('外部链接未配置')
return
}
window.api.OpenUrl(tool.action.url)
break
case 'dialog':
//
console.log('打开弹窗:', tool.action.component)
break
default:
message.error(`未知操作类型: ${tool.action?.type || '无'}`)
}
}
function resetFilters() {
searchKeyword.value = ''
selectedCategory.value = null
selectedTag.value = null
activeCategory.value = 'all'
}
//
onMounted(() => {})
</script>
<style scoped>
.toolbox-home {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.search-bar {
flex: 1;
min-width: 300px;
}
.filter-bar {
flex-shrink: 0;
}
.category-tabs {
margin-bottom: 32px;
}
.quick-access,
.recent-tools {
margin-bottom: 24px;
}
.stats-section {
margin-top: 32px;
padding: 20px;
background: var(--n-card-color);
border-radius: 8px;
border: 1px solid var(--n-border-color);
}
@media (max-width: 768px) {
.toolbox-home {
padding: 16px;
}
.header-section {
flex-direction: column;
align-items: stretch;
}
.search-bar {
min-width: auto;
}
}
</style>

View File

@ -0,0 +1,153 @@
<template>
<div class="tool-grid">
<div v-for="tool in tools" :key="tool.id" class="tool-card" @click="$emit('toolClick', tool)">
<n-card hoverable :class="{ 'tool-card-disabled': tool.disabled }" size="small">
<div class="tool-content">
<div class="tool-icon">
<n-icon size="32">
<component :is="tool.icon" />
</n-icon>
</div>
<div class="tool-info">
<h3 class="tool-name">{{ tool.name }}</h3>
<p class="tool-description">{{ tool.description }}</p>
<div class="tool-tags" v-if="tool.tags && tool.tags.length > 0">
<n-tag v-for="tag in tool.tags" :key="tag" size="tiny" :bordered="false" type="info">
{{ tag }}
</n-tag>
</div>
</div>
<div class="tool-badge" v-if="tool.badge">
<n-badge :value="tool.badge.text" :type="tool.badge.type" />
</div>
</div>
</n-card>
</div>
<!-- 空状态 -->
<div v-if="tools.length === 0" class="empty-state">
<n-empty description="没有找到相关工具" />
</div>
</div>
</template>
<script setup>
import { NCard, NIcon, NTag, NBadge, NSpace, NEmpty } from 'naive-ui'
defineProps({
tools: {
type: Array,
default: () => []
}
})
defineEmits(['toolClick'])
</script>
<style scoped>
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.tool-card {
cursor: pointer;
transition: transform 0.2s ease;
}
.tool-card:hover {
transform: translateY(-2px);
}
.tool-card-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tool-card-disabled:hover {
transform: none;
}
.tool-content {
display: flex;
align-items: flex-start;
gap: 12px;
position: relative;
height: 80px;
}
.tool-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--n-color-target);
border-radius: 8px;
}
.tool-info {
flex: 1;
min-width: 0;
}
.tool-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: var(--n-text-color);
line-height: 1.2;
}
.tool-description {
margin: 0 0 8px 0;
font-size: 13px;
color: var(--n-text-color-depth-2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tool-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tool-badge {
position: absolute;
top: -4px;
right: -4px;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
@media (max-width: 768px) {
.tool-grid {
grid-template-columns: 1fr;
}
.tool-content {
flex-direction: column;
text-align: center;
}
.tool-icon {
align-self: center;
}
}
</style>

View File

@ -78,11 +78,6 @@ const routes = [
name: 'videogeneratesetting',
component: () => import('./components/Setting/VideoGenerateSetting.vue')
},
{
path: '/ShowMessage',
name: 'ShowMessage',
component: () => import('./components/Home/ShowMessage.vue')
},
{
path: '/sdoriginal',
name: 'sdoriginal',
@ -122,6 +117,21 @@ const routes = [
path: '/TTS_Services',
name: 'TTS_Services',
component: () => import('./components/TTS/TTSHome.vue')
},
{
path: '/toolbox',
name: 'toolbox',
component: () => import('./components/ToolBox/ToolBoxHome.vue')
},
{
path: '/toolbox/image-upload',
name: 'image-upload',
component: () => import('./components/ToolBox/ImageUpload/ImageUploadHome.vue')
},
{
path: '/toolbox/image-compress',
name: 'image-compress',
component: () => import('./components/ToolBox/ImageCompress/ImageCompressHome.vue')
}
]
},