Merge branch 'main' of https://git.nianxx.cn/duanshuwen/zn-ai into feature/lishaohua
# Conflicts: # src/renderer/components/TaskList/index.vue # src/renderer/views/home/index.vue
This commit is contained in:
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen bg-gray-100">
|
||||
<div class="flex items-center gap-1 px-3 h-10 border-b bg-white" style="-webkit-app-region: drag">
|
||||
<div class="flex items-center gap-2 mr-2">
|
||||
<button class="w-3 h-3 rounded-full bg-red-500" style="-webkit-app-region: no-drag" @click="onCloseWindow"></button>
|
||||
<button class="w-3 h-3 rounded-full bg-yellow-400" style="-webkit-app-region: no-drag" @click="onMinimizeWindow"></button>
|
||||
<button class="w-3 h-3 rounded-full bg-green-500" style="-webkit-app-region: no-drag" @click="onMaximizeWindow"></button>
|
||||
</div>
|
||||
<div v-for="t in tabs" :key="t.id" class="flex items-center px-2 py-1 rounded cursor-pointer" :class="t.id===activeId?'bg-blue-100':'hover:bg-gray-200'" @click="onSwitch(t.id)" style="-webkit-app-region: no-drag">
|
||||
<span class="text-sm mr-2 truncate max-w-[160px]">{{ t.title || t.url || '新标签页' }}</span>
|
||||
<button class="text-gray-600 hover:text-black" style="-webkit-app-region: no-drag" @click.stop="onCloseTabId(t.id)">✕</button>
|
||||
</div>
|
||||
<button class="ml-2 px-2 py-1 rounded bg-gray-200 hover:bg-gray-300" style="-webkit-app-region: no-drag" @click="onNewTab">+</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 h-12 border-b bg-gray-50">
|
||||
<button class="px-2 py-1 bg-gray-200 rounded" @click="onBack" :disabled="!active?.canGoBack">←</button>
|
||||
<button class="px-2 py-1 bg-gray-200 rounded" @click="onForward" :disabled="!active?.canGoForward">→</button>
|
||||
<button class="px-2 py-1 bg-gray-200 rounded" @click="onReload">⟳</button>
|
||||
<input class="flex-1 px-3 py-1 border rounded-full" v-model="address" @keyup.enter="onNavigate" placeholder="输入地址后回车" />
|
||||
</div>
|
||||
<div class="h-[calc(100vh-5.5rem)]"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'
|
||||
|
||||
type TabInfo = { id: string; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||
|
||||
const tabs = reactive<TabInfo[]>([])
|
||||
const activeId = ref<string>('')
|
||||
const address = ref<string>('')
|
||||
|
||||
const active = computed(() => tabs.find(t => t.id === activeId.value))
|
||||
|
||||
const refreshActiveAddress = () => {
|
||||
address.value = active.value?.url || ''
|
||||
}
|
||||
|
||||
const syncList = async () => {
|
||||
const list: TabInfo[] = await (window as any).ipcAPI.tabs.list()
|
||||
tabs.splice(0, tabs.length, ...list)
|
||||
if (!activeId.value && list.length > 0) activeId.value = list[0].id
|
||||
refreshActiveAddress()
|
||||
}
|
||||
|
||||
const onNewTab = async () => {
|
||||
const info: TabInfo = await (window as any).ipcAPI.tabs.create('about:blank')
|
||||
activeId.value = info.id
|
||||
}
|
||||
|
||||
const onSwitch = async (id: string) => {
|
||||
await (window as any).ipcAPI.tabs.switch(id)
|
||||
activeId.value = id
|
||||
refreshActiveAddress()
|
||||
}
|
||||
|
||||
const onCloseTab = async () => {
|
||||
if (!activeId.value) return
|
||||
await (window as any).ipcAPI.tabs.close(activeId.value)
|
||||
}
|
||||
|
||||
const normalizeUrl = (u: string) => {
|
||||
const trimmed = u.trim()
|
||||
if (!trimmed) return ''
|
||||
if (/^(https?:|file:|about:)/i.test(trimmed)) return trimmed
|
||||
return `https://${trimmed}`
|
||||
}
|
||||
|
||||
const onNavigate = async () => {
|
||||
if (!activeId.value || !address.value) return
|
||||
const url = normalizeUrl(address.value)
|
||||
if (!url) return
|
||||
await (window as any).ipcAPI.tabs.navigate(activeId.value, url)
|
||||
}
|
||||
|
||||
const onReload = async () => {
|
||||
if (!activeId.value) return
|
||||
await (window as any).ipcAPI.tabs.reload(activeId.value)
|
||||
}
|
||||
|
||||
const onBack = async () => {
|
||||
if (!activeId.value) return
|
||||
await (window as any).ipcAPI.tabs.back(activeId.value)
|
||||
}
|
||||
|
||||
const onForward = async () => {
|
||||
if (!activeId.value) return
|
||||
await (window as any).ipcAPI.tabs.forward(activeId.value)
|
||||
}
|
||||
|
||||
const onCloseTabId = async (id: string) => {
|
||||
await (window as any).ipcAPI.tabs.close(id)
|
||||
}
|
||||
|
||||
const onCloseWindow = () => (window as any).ipcAPI.window.close()
|
||||
const onMinimizeWindow = () => (window as any).ipcAPI.window.minimize()
|
||||
const onMaximizeWindow = () => (window as any).ipcAPI.window.maximize()
|
||||
|
||||
onMounted(async () => {
|
||||
await syncList()
|
||||
;(window as any).ipcAPI.tabs.on('tab-created', (info: TabInfo) => {
|
||||
const i = tabs.findIndex(t => t.id === info.id)
|
||||
if (i === -1) tabs.push(info)
|
||||
activeId.value = info.id
|
||||
refreshActiveAddress()
|
||||
})
|
||||
;(window as any).ipcAPI.tabs.on('tab-updated', (info: TabInfo) => {
|
||||
const i = tabs.findIndex(t => t.id === info.id)
|
||||
if (i >= 0) tabs[i] = info
|
||||
if (activeId.value === info.id) refreshActiveAddress()
|
||||
})
|
||||
;(window as any).ipcAPI.tabs.on('tab-closed', ({ tabId }: { tabId: string }) => {
|
||||
const i = tabs.findIndex(t => t.id === tabId)
|
||||
if (i >= 0) tabs.splice(i, 1)
|
||||
if (activeId.value === tabId) {
|
||||
const next = tabs[0]
|
||||
activeId.value = next?.id || ''
|
||||
refreshActiveAddress()
|
||||
}
|
||||
})
|
||||
;(window as any).ipcAPI.tabs.on('tab-switched', ({ tabId }: { tabId: string }) => {
|
||||
activeId.value = tabId
|
||||
refreshActiveAddress()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
# Electron 多标签浏览器实现计划(高性能方案)
|
||||
|
||||
## 目标
|
||||
- 在现有 Electron + Vue + TypeScript + TailwindCSS 项目中实现类似 Chrome 的多标签页浏览体验:创建、切换、关闭、前进、后退、刷新、地址输入、拓展插件入口、收藏入口。
|
||||
- 采用高性能与安全优先的架构:主进程 `BrowserView` 管理;渲染层仅通过受控 API;IPC 类型明确;持久化与插件机制可扩展。
|
||||
|
||||
## 当前项目结构与集成点
|
||||
- 主进程入口:`src/main.ts`(窗口创建与 IPC 注册,参考 `src/main.ts:11-57`)
|
||||
- 预加载:`src/preload.ts`(已暴露 `window.api`,参考 `src/preload.ts:7-17`)
|
||||
- 渲染层入口:`index.html` + `src/renderer.ts`(Vue 应用挂载,参考 `src/renderer.ts:35-45`)
|
||||
- 现有 IPC 控制器:`src/controller/changeWindowSize.js`(窗口操作)
|
||||
|
||||
## 高性能技术方案
|
||||
- 选型:使用 `BrowserView` 每个标签一个 `BrowserView`,由主进程统一管理。
|
||||
- 原因:`BrowserView` 与主窗口同进程但独立渲染,API 完整(`webContents` 导航、生命周期事件),相较 `<webview>` 更易管控、性能与兼容性更好。
|
||||
- 视图复用与生命周期:
|
||||
- 活跃标签:将其 `BrowserView` attach 到主窗口;非活跃标签:detach(保留引用与状态),避免多视图同时渲染占用资源。
|
||||
- 限制最大同时 attach 数量(默认 1),保障 GPU/CPU 负载稳定。
|
||||
- 会话与隔离:
|
||||
- 默认共享 `session`(`partition: 'persist:main'`),减少多会话开销。
|
||||
- 可按需支持隔离会话(隐私标签):`partition: 'tab:<id>'`。
|
||||
- 导航性能与安全:
|
||||
- 导航统一通过主进程 `view.webContents.loadURL(url)`;前进/后退/刷新使用 `goBack/goForward/reload`。
|
||||
- 外链打开统一通过 `shell.openExternal(url)`,白名单校验(已存在 `external-open` 通道,参考 `src/main.ts:33-43`)。
|
||||
- 地址栏与状态:
|
||||
- 渲染层地址栏仅发起 IPC;主进程执行导航并广播当前标签的 `url/title/loading/historyState`。
|
||||
- 书签与持久化:
|
||||
- 存储方案优先使用 `electron-store`(JSON,轻量、跨平台);高并发或大数据可切换 `better-sqlite3`。
|
||||
- 插件入口:
|
||||
- 设计内部插件机制(非 Chrome 扩展):定义 `onTabCreated/onNavigate/beforeLoad/afterLoad` 等钩子;插件注册于主进程。
|
||||
- Chrome 扩展仅作为可选(`session.loadExtension`),受限于兼容性与审核,不作为默认方案。
|
||||
|
||||
## 模块设计
|
||||
### 主进程(`src/main/`)
|
||||
- `TabManager`:管理 `BrowserView` 生命周期与状态
|
||||
- 方法:`create(url?)`、`switch(tabId)`、`close(tabId)`、`navigate(tabId, url)`、`reload(tabId)`、`goBack(tabId)`、`goForward(tabId)`、`list()`。
|
||||
- 事件:`tab-updated`(title/url/loading)、`tab-created`、`tab-closed`、`tab-switched`。
|
||||
- `IPCRegistry`:集中注册通道
|
||||
- `tab:create | tab:switch | tab:close | tab:navigate | tab:reload | tab:back | tab:forward | tab:list`
|
||||
- `bookmark:add | bookmark:remove | bookmark:list | bookmark:folders`
|
||||
- `plugin:invoke(hook, payload)`(内部插件钩子转发)
|
||||
- `BookmarkStore`:封装 `electron-store` 或 SQLite(可插拔)
|
||||
- `PluginHost`:插件注册与钩子执行(有序、可熔断)
|
||||
|
||||
### 预加载(`src/preload.ts`)
|
||||
- 扩展现有 `window.api`:
|
||||
- `tabs.*`:与上述 IPC 一一对应,统一 `invoke` 调用;`tabs.on(event, handler)` 用于订阅主进程广播(通过 `ipcRenderer.on` 包装)。
|
||||
- `bookmarks.*`:增删改查接口。
|
||||
- `plugins.invoke(hook, payload)`:触发插件钩子。
|
||||
|
||||
### 渲染层(Vue)
|
||||
- 布局页面:`BrowserLayout`(地址栏、标签条、控制区、书签/插件入口)
|
||||
- 组件:
|
||||
- `TabBar`:显示标签列表,支持新建/切换/关闭、拖拽排序(后续)
|
||||
- `AddressBar`:URL 输入与状态展示(加载中、锁标识、HTTPS)
|
||||
- `Controls`:后退/前进/刷新/新建标签按钮
|
||||
- `BookmarksPane`:收藏夹入口(侧栏或菜单)
|
||||
- `PluginsMenu`:插件入口(菜单或面板)
|
||||
- 路由:`/browser` 作为应用主界面;初始打开一个空白或主页标签。
|
||||
|
||||
## IPC 通道与类型(示例)
|
||||
```ts
|
||||
type TabId = string;
|
||||
interface TabInfo { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
|
||||
|
||||
// 请求
|
||||
tab:create(url?: string) => TabInfo
|
||||
tab:switch(tabId: TabId) => void
|
||||
tab:close(tabId: TabId) => void
|
||||
tab:navigate({ tabId, url }: { tabId: TabId, url: string }) => void
|
||||
tab:reload(tabId: TabId) => void
|
||||
tab:back(tabId: TabId) => void
|
||||
tab:forward(tabId: TabId) => void
|
||||
tab:list() => TabInfo[]
|
||||
|
||||
// 广播
|
||||
tab-updated: TabInfo
|
||||
tab-created: TabInfo
|
||||
tab-closed: { tabId: TabId }
|
||||
tab-switched: { tabId: TabId }
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
- `Tab`:`{ id, view, url, title, isLoading, createdAt }`
|
||||
- `Bookmark`:`{ id, title, url, folderId?, createdAt }`
|
||||
- `Folder`:`{ id, name, parentId? }`
|
||||
- `Plugin`:`{ id, name, hooks }`
|
||||
|
||||
## 性能优化要点
|
||||
- 仅 attach 当前活跃 `BrowserView` 到窗口;对非活跃标签 `detach` 保留状态。
|
||||
- 控制最大标签数(例如 20),超过时启用 LRU 释放策略(可配置)。
|
||||
- 启用硬件加速,保持默认;避免禁用 GPU。
|
||||
- 监听 `did-stop-loading`/`did-finish-load` 更新状态,减少渲染层轮询。
|
||||
- 对地址栏与状态广播使用节流(例如 100ms)。
|
||||
- 持久化异步批量写入(书签、会话恢复)。
|
||||
|
||||
## 安全策略
|
||||
- 保持 `contextIsolation: true`、`sandbox: true`、`nodeIntegration: false`(已在 `src/main.ts:20-26`)
|
||||
- 外链统一 `shell.openExternal` + 白名单(已在 `src/main.ts:33-43`)
|
||||
- 严格 CSP 保留于渲染层 `index.html`;`BrowserView` 加载外域不受该 CSP 限制,但需根据业务限制域名。
|
||||
|
||||
## 迭代计划(三阶段)
|
||||
1. 核心能力(主进程 + IPC + 预加载)
|
||||
- 实现 `TabManager` 与全部导航方法
|
||||
- 注册 IPC(tabs/bookmarks/plugins)与广播
|
||||
- 预加载扩展 `window.ipcAPI.tabs/*`、事件订阅封装
|
||||
2. 渲染层界面
|
||||
- 新建 `BrowserLayout` 与基础组件(TabBar、AddressBar、Controls)
|
||||
- 打通地址栏与导航;同步标题与加载状态
|
||||
- 书签入口基础增删与列表展示
|
||||
3. 插件与高级能力
|
||||
- `PluginHost` 与钩子机制;内置示例插件(如:拦截导航统计)
|
||||
- 标签拖拽排序、会话恢复、快捷键(Ctrl/Cmd+T/W、Ctrl/Cmd+L、Ctrl/Cmd+R、Alt+←/→)
|
||||
|
||||
## 集成步骤(细化)
|
||||
- 主进程:新增 `src/main/tab-manager.ts`、`src/main/ipc.ts`、`src/main/bookmark-store.ts`、`src/main/plugin-host.ts`;在 `src/main.ts` 初始化与注册。
|
||||
- 预加载:扩展 `window.api`,新增 `tabs`、`bookmarks`、`plugins` 命名空间。
|
||||
- 渲染层:新增 `/browser` 页面与组件,替换应用首页或通过路由进入。
|
||||
|
||||
## 决策点(需确认)
|
||||
1. 标签视图方案:仅 `BrowserView`(推荐),允许 `<webview>` 兼容模式
|
||||
2. 最大标签数量与释放策略:默认 20,接受LRU 释放
|
||||
3. 会话策略:全部共享 `session` ,支持隐私标签独立 `partition`
|
||||
4. 书签存储:默认 `electron-store` ,暂不考虑 SQLite(`better-sqlite3`)
|
||||
5. 插件能力边界:需要支持加载 Chrome 扩展(`session.loadExtension`)
|
||||
6. 首页与地址栏行为:默认主页 URL、空白页(`about:blank`),可自定义欢迎页
|
||||
7. 路由集成:将 `/browser` 作为默认首页,保留现有 `/about` 等页面
|
||||
|
||||
## 验收与测试
|
||||
- 开发启动:`npm run start`,验证创建/切换/关闭/导航/刷新基本路径
|
||||
- 事件广播:在 Vue 中订阅 `tab-updated`,确保标题/加载进度实时更新
|
||||
- 书签:新增/删除/持久化验证与重启恢复
|
||||
- 性能:在 10+ 标签场景观察 CPU/GPU 占用与 UI 响应
|
||||
- 安全:尝试非法域名导航,验证白名单拦截与错误提示
|
||||
|
||||
---
|
||||
后续我将基于以上计划逐步实现。在“决策点”中的问题请先确认,我将据此微调实现(例如是否支持隐私标签、是否引入 SQLite、是否兼容 `<webview>`)。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
**待反馈决策**
|
||||
- 顶部 UI 高度与 `BrowserView` 边界
|
||||
- 当前使用固定偏移 `64px` 对齐顶部 UI。是否改为渲染层动态上报高度(例如在布局变化时通过 IPC 设置),以适配不同分辨率与主题?
|
||||
- Chrome 扩展加载
|
||||
- 文档中确认“需要支持加载 Chrome 扩展”。建议作为后续阶段实现,采用 `session.loadExtension` 并隔离到特定 `partition`,以减少对主会话的影响;请确认是否需要默认加载的扩展清单或仅提供入口。
|
||||
|
||||
如果以上决策点确认,我将继续第二阶段:完善 UI 高度动态设置与扩展加载入口,同时补充书签持久化(基于 `userData` 目录 JSON 文件)与插件钩子框架。
|
||||
7
src/renderer/views/dashboard/index.vue
Normal file
7
src/renderer/views/dashboard/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>看板</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<Layout>
|
||||
</Layout>
|
||||
<div>
|
||||
首页
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const openBaidu = () => {
|
||||
(window as any).ipcAPI?.openBaidu()
|
||||
|
||||
// 发送日志
|
||||
(window as any).ipcAPI?.logToMain('info', '打开百度')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-screen box-border p-[8px] login-bg flex items-center justify-center"
|
||||
>
|
||||
<div class="h-screen box-border p-[8px] login-bg flex items-center justify-center">
|
||||
<div class="w-[836px] h-full bg-white rounded-2xl p-[32px] flex flex-col">
|
||||
<div class="flex items-center">
|
||||
<img class="w-[48px] h-[48px]" src="@assets/images/login/blue_logo.png" />
|
||||
|
||||
|
||||
<span class="ml-auto text-[14px] text-gray-600">没有账号?</span>
|
||||
<button class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none">注册</button>
|
||||
<button
|
||||
class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none">注册</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[108px]">
|
||||
@@ -18,39 +17,24 @@
|
||||
|
||||
<div class="w-[392px] ml-auto mr-auto">
|
||||
<div class="font-[14px] text-gray-700 mb-2">账号</div>
|
||||
<div class="border rounded-[10px] flex items-center gap-2 box-border px-[12px] py-[10px]">
|
||||
<RiUser3Fill size="20px" color="#99A0AE" />
|
||||
<input
|
||||
class="flex-1 focus-visible:outline-none"
|
||||
type="text"
|
||||
v-model.trim="form.account"
|
||||
placeholder="请输入账号"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.account" class="mt-1 text-xs text-red-500">{{ errors.account }}</p>
|
||||
<div class="border rounded-[10px] flex items-center gap-2 box-border px-[12px] py-[10px]">
|
||||
<RiUser3Fill size="20px" color="#99A0AE" />
|
||||
<input class="flex-1 focus-visible:outline-none" type="text" v-model.trim="form.account" placeholder="请输入账号"
|
||||
@keyup.enter="onSubmit" />
|
||||
</div>
|
||||
<p v-if="errors.account" class="mt-1 text-xs text-red-500">{{ errors.account }}</p>
|
||||
<div class="font-[14px] text-gray-700 mb-[8px] mt-[12px]">密码</div>
|
||||
<div class="flex items-center gap-2 border rounded-[10px] box-border px-[12px] py-[10px]">
|
||||
<RiKey2Fill size="20px" color="#99A0AE" />
|
||||
<input
|
||||
class="flex-1 focus-visible:outline-none"
|
||||
:type="showPwd ? 'text' : 'password'"
|
||||
v-model.trim="form.password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
<input class="flex-1 focus-visible:outline-none" :type="showPwd ? 'text' : 'password'"
|
||||
v-model.trim="form.password" placeholder="请输入密码" @keyup.enter="onSubmit" />
|
||||
</div>
|
||||
<p v-if="errors.password" class="mt-1 text-xs text-red-500">{{ errors.password }}</p>
|
||||
<!-- 验证码 -->
|
||||
<div class="font-[14px] text-gray-700 mb-[8px] mt-[12px]">验证码</div>
|
||||
<div class="flex items-center gap-2 border rounded-[10px] box-border px-[12px] py-[10px]">
|
||||
<input
|
||||
class="flex-1 focus-visible:outline-none"
|
||||
type="text"
|
||||
v-model.trim="form.code"
|
||||
placeholder="请输入验证码"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
<input class="flex-1 focus-visible:outline-none" type="text" v-model.trim="form.code" placeholder="请输入验证码"
|
||||
@keyup.enter="onSubmit" />
|
||||
<img class="w-[80px] h-[40px]" src="" />
|
||||
</div>
|
||||
<p v-if="errors.code" class="mt-1 text-xs text-red-500">{{ errors.code }}</p>
|
||||
@@ -58,31 +42,21 @@
|
||||
<!-- 记住密码|忘记密码 -->
|
||||
<div class="flex items-center justify-between mb-[24px] mt-[24px]">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showPwd"
|
||||
class="w-[14px] h-[14px] rounded-[4px]"
|
||||
/>
|
||||
<input type="checkbox" v-model="showPwd" class="w-[14px] h-[14px] rounded-[4px]" />
|
||||
<span class="text-[14px] text-gray-600">记住密码</span>
|
||||
</div>
|
||||
<span class="text-[14px] text-sky-600">忘记密码?</span>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
class="w-full py-2 bg-blue-600 text-white rounded-[8px] hover:bg-blue-700 disabled:bg-blue-300"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<button class="w-full py-2 bg-blue-600 text-white rounded-[8px] hover:bg-blue-700 disabled:bg-blue-300"
|
||||
@click="onSubmit">
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
|
||||
<!-- 同意协议 -->
|
||||
<div class="flex items-center justify-center gap-2 mt-[24px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.agreement"
|
||||
class="w-[14px] h-[14px] rounded-[4px]"
|
||||
/>
|
||||
<input type="checkbox" v-model="form.agreement" class="w-[14px] h-[14px] rounded-[4px]" />
|
||||
<span class="text-[14px] text-gray-600">我已同意</span>
|
||||
<span class="text-[14px] text-sky-600">《使用协议》</span>
|
||||
<span class="text-[14px] text-gray-600">和</span>
|
||||
@@ -103,8 +77,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { login as apiLogin } from "@/renderer/api/login";
|
||||
import { RiUser3Fill , RiKey2Fill} from '@remixicon/vue'
|
||||
// import { login as apiLogin } from "@/renderer/api/login";
|
||||
import { RiUser3Fill, RiKey2Fill } from '@remixicon/vue'
|
||||
|
||||
const router = useRouter();
|
||||
const form = reactive({ account: "", password: "", agreement: "", code: "" });
|
||||
@@ -131,7 +105,8 @@ const onSubmit = async () => {
|
||||
// const token = res && (res.token || res.data?.token || res.access_token);
|
||||
// if (!token) throw new Error("登录失败");
|
||||
// localStorage.setItem("token", token);
|
||||
await (window as any).ipcAPI.app.setFrameless('/home')
|
||||
// await (window as any).ipcAPI.app.setFrameless('/home')
|
||||
router.push('/home');
|
||||
} finally {
|
||||
// loading.value = false;
|
||||
}
|
||||
|
||||
7
src/renderer/views/more/index.vue
Normal file
7
src/renderer/views/more/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>更多</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
7
src/renderer/views/order/index.vue
Normal file
7
src/renderer/views/order/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>订单</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex flex-col box-border border-[1px] border-[#E5E8EE] rounded-[16px] p-[16px]">
|
||||
<el-tabs v-model="activeName">
|
||||
<el-tab-pane :label="`${item.label}(${item.total})`" :name="item.name" v-for="item in tabs"
|
||||
:key="item.label" />
|
||||
</el-tabs>
|
||||
|
||||
<RateFilterSection />
|
||||
|
||||
<RateListSection :list="commentList" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination v-model:current-page="queryParams.pageIndex" :page-size="queryParams.pageSize" :total="total" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="RateContentSection">
|
||||
import { ref } from 'vue'
|
||||
import { commentList } from '@/constant/rate'
|
||||
import Pagination from '@/components/Pagination/index.vue'
|
||||
import RateFilterSection from '../RateFilterSection/index.vue'
|
||||
import RateListSection from '../RateListSection/index.vue'
|
||||
|
||||
const activeName = ref('all')
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '全部评价',
|
||||
name: 'all',
|
||||
total: 200,
|
||||
},
|
||||
{
|
||||
label: '待回复',
|
||||
name: 'second',
|
||||
total: 100,
|
||||
},
|
||||
{
|
||||
label: '差评',
|
||||
name: 'third',
|
||||
total: 50,
|
||||
}
|
||||
])
|
||||
const list = ref([])
|
||||
const total = ref(200)
|
||||
const queryParams = ref({
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-[12px]">
|
||||
<el-form ref="queryRef" :inline="true" :model="queryParams" @keyup.enter="handleQuery">
|
||||
<el-form-item prop="dateRange">
|
||||
<el-date-picker v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" range-separator="-"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" style="width: 240px;" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="channel">
|
||||
<el-select v-model="queryParams.channel" placeholder="请选择渠道" style="width: 240px;">
|
||||
<el-option v-for="item in channels" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="RateFilterSection">
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
|
||||
const { proxy }: any = getCurrentInstance()
|
||||
const queryParams = ref({
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
channel: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
const dateRange = ref([])
|
||||
const channels = [
|
||||
{
|
||||
label: '全部',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: '携程',
|
||||
value: 'ctrip',
|
||||
},
|
||||
{
|
||||
label: '飞猪',
|
||||
value: 'flyingpig',
|
||||
},
|
||||
{
|
||||
label: '抖音',
|
||||
value: 'douyin',
|
||||
},
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const queryRef = ref()
|
||||
|
||||
/** 查询评论列表 */
|
||||
function getList() {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageIndex = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
dateRange.value = []
|
||||
queryRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
</script>
|
||||
37
src/renderer/views/rate/components/RateListSection/index.vue
Normal file
37
src/renderer/views/rate/components/RateListSection/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="list h-[420px] px-[16px] overflow-y-auto">
|
||||
<div class="mb-[16px]" v-for="item in list" :key="item.id">
|
||||
<div class="flex items-center text-[14px] select-none">
|
||||
<el-image :src="item.channelIcon" />
|
||||
<span class="text-[#1B74F5] ml-[12px]">{{ item.channelName }}</span>
|
||||
<span class="text-[#1B74F5] mx-[3px]">-</span>
|
||||
<span class="text-[#171717]">{{ item.userName }}</span>
|
||||
<span class="text-[#99A0AE] mx-[6px]">·</span>
|
||||
<span class="text-[#2B7FFF]">评分{{ item.score }}</span>
|
||||
<span class="text-[#99A0AE] ml-auto">{{ item.createTime }}</span>
|
||||
</div>
|
||||
<div class="content pl-[60px]">
|
||||
<p class="text-[14px] text-[#525866] leading-[20px] mb-[8px]">{{ item.content }}</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-[8px] w-[316px]">
|
||||
<el-image :src="img" class="w-[100px] h-[100px] rounded-[8px]"
|
||||
v-for="(img, index) in item.imageList" :key="index" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="reply pl-[60px]">
|
||||
<span class="text-[14px] text-[#2B7FFF] cursor-pointer">回复</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="RateContentSection">
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
defineProps({
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-5 gap-[15px] mb-[20px] select-none">
|
||||
<div class="bg-[#F5F7FA] box-border flex flex-col p-[16px] rounded-[12px]" v-for="item in channels" :key="item.id">
|
||||
<div class="flex items-center mb-[8px]">
|
||||
<img :src="item.icon" class="w-[24px] h-[24px] mr-[8px]">
|
||||
<span class="text-[16px] font-400 text-[#171717] leading-[24px]">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[14px] font-400 text-[#171717] leading-[20px] mb-[4px]">
|
||||
{{ item.total }}条
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<RiStarFill :color="i <= item.score ? '#FA7319' : '#CACFD8'" class="w-[20px] h-[20px] mr-[2px]" v-for="i in 5"
|
||||
:key="i" />
|
||||
<span class="text-[14px] text-[#525866] leading-[20px] pl-[2px]">
|
||||
{{ item.score }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="RatePanelSection">
|
||||
import { channels } from '@constant/rate'
|
||||
import { RiStarFill } from '@remixicon/vue'
|
||||
</script>
|
||||
BIN
src/renderer/views/rate/images/2.jpg
Normal file
BIN
src/renderer/views/rate/images/2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<Layout>
|
||||
|
||||
</Layout>
|
||||
<div class="bg-white box-border w-full h-full rounded-[16px] p-[20px]">
|
||||
<TitleSection title="评价" desc="评价数据智能整理,精准优化服务" />
|
||||
<RatePanelSection />
|
||||
<RateContentSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const openBaidu = () => {
|
||||
(window as any).ipcAPI?.openBaidu()
|
||||
|
||||
// 发送日志
|
||||
(window as any).ipcAPI?.logToMain('info', '打开百度')
|
||||
}
|
||||
<script setup lang="ts" name="Rate">
|
||||
import TitleSection from '@/components/TitleSection/index.vue'
|
||||
import RatePanelSection from './components/RatePanelSection/index.vue'
|
||||
import RateContentSection from './components/RateContentSection/index.vue'
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex-1 h-full p-[20px] select-none">
|
||||
<TitleSection title="账号设置" desc="请关联PMS和渠道房型名称,可使用智能对标" />
|
||||
|
||||
<div
|
||||
class="w-full flex items-center mt-[20px] py-[20px] box-border border-b-[1px] border-dashed border-b-[#E5E8EE]">
|
||||
<div class="label w-[64px] text-[16px] font-medium text-[#171717] mr-[24px]">账号</div>
|
||||
<div class="value text-[14px] font-medium text-[#171717]">1234567890</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center py-[20px] box-border border-b-[1px] border-dashed border-b-[#E5E8EE]">
|
||||
<div class="label w-[64px] text-[16px] font-medium text-[#171717] mr-[24px]">登录密码</div>
|
||||
<div class="value text-[14px] text-[#99A0AE]">保障投资者登录操作时使用,上次登录时间:2022-11-09 16:24:30</div>
|
||||
|
||||
<div class="border-[1px] border-[#E5E8EE] rounded-[6px] px-[6px] py-[4px] flex items-center ml-[24px]">
|
||||
<RiCheckboxCircleFill class="w-[16px] h-[16px]" color="#1FC16B" />
|
||||
<span class="text-[12px] text-[#525866] ml-[2px]">已设置</span>
|
||||
</div>
|
||||
|
||||
<el-button type="text" class="ml-auto">修改密码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RiCheckboxCircleFill } from '@remixicon/vue'
|
||||
import TitleSection from '@/components/TitleSection/index.vue'
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex-1 h-full p-[20px] select-none">
|
||||
<TitleSection title="渠道管理" desc="绑定酒店使用的相关渠道的账户和密码用于智能操作" />
|
||||
|
||||
<div class="grid grid-cols-3 gap-[12px] mb-[12px] select-none">
|
||||
<div class="border-[1px] border-[#E5E8ED] box-border flex flex-col rounded-[12px] overflow-hidden"
|
||||
v-for="item in channel" :key="item.id">
|
||||
<div class="bg-[#E0E0E0] h-[120px]"></div>
|
||||
<div class="flex items-center relative mt-[-20px] pl-[12px]">
|
||||
<img :src="item.icon" class="w-[40px] h-[40px]">
|
||||
</div>
|
||||
<div class="flex items-center p-[12px]">
|
||||
<span class="text-[14px] font-500 text-[#171717] leading-[20px]">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<div class="bg-[#F2F5F8] rounded-[6px] flex items-center ml-[8px] py-[4px] px-[6px]">
|
||||
<RiForbidLine class="w-[16px] h-[16px]" color="#717784" />
|
||||
<span class="text-[12px] font-500 text-[#717784] leading-[20px] ml-[4px]">
|
||||
未绑定
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-[12px] border-t-[1px] border-t-[#E5E8ED]">
|
||||
<el-button plain>绑定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { channel } from '@constant/channel'
|
||||
import { RiForbidLine } from '@remixicon/vue'
|
||||
import TitleSection from '@/components/TitleSection/index.vue'
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template></template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
27
src/renderer/views/setting/components/SystemConfig/index.vue
Normal file
27
src/renderer/views/setting/components/SystemConfig/index.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-[136px] h-full box-border border-r-[1px] border-r-[#E5E8EE] py-[12px] px-[8px] flex flex-col gap-[4px] select-none">
|
||||
<div class="text-[12px] text-[#99A0AE] p-[4px]">系统设置</div>
|
||||
|
||||
<div
|
||||
:class="['box-border flex items-center py-[10px] px-[12px] rounded-[6px] cursor-pointer', item.id === currentId ? 'bg-[#EFF6FF]' : '']"
|
||||
v-for="item in systemMenus" :key="item.id" @click="handleClick(item)">
|
||||
<component :is="item.icon" :color="item.id === currentId ? item.activeColor : item.color"
|
||||
class="w-[20px] h-[20px]" />
|
||||
<span class="box-border px-[8px] text-[14px] font-medium text-[#525866]">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineEmits } from 'vue'
|
||||
import { systemMenus } from '@/constant/system-config'
|
||||
|
||||
const currentId = ref(1)
|
||||
|
||||
const emits = defineEmits(['change'])
|
||||
const handleClick = async (item: any) => {
|
||||
currentId.value = item.id
|
||||
emits('change', item)
|
||||
}
|
||||
</script>
|
||||
16
src/renderer/views/setting/components/Version/index.vue
Normal file
16
src/renderer/views/setting/components/Version/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="flex-1 h-full p-[20px] select-none">
|
||||
<TitleSection title="账号设置" desc="请关联PMS和渠道房型名称,可使用智能对标" />
|
||||
|
||||
<div
|
||||
class="w-full flex items-center mt-[20px] py-[20px] box-border border-b-[1px] border-dashed border-b-[#E5E8EE]">
|
||||
<div class="label w-[64px] text-[16px] font-medium text-[#171717] mr-[24px]">当前版本</div>
|
||||
<div class="value text-[16px] font-medium text-[#171717]">1.0.0</div>
|
||||
<el-button type="text" class="ml-auto">检查更新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TitleSection from '@/components/TitleSection/index.vue'
|
||||
</script>
|
||||
25
src/renderer/views/setting/index.vue
Normal file
25
src/renderer/views/setting/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="bg-white box-border w-full h-full rounded-[16px] flex">
|
||||
<SystemConfig @change=onChange />
|
||||
|
||||
<component :is="currentComponent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
import SystemConfig from './components/SystemConfig/index.vue'
|
||||
import AccountSetting from './components/AccountSetting/index.vue'
|
||||
import Version from './components/Version/index.vue'
|
||||
import ChannelSetting from './components/ChannelSetting/index.vue'
|
||||
import RoomTypeSetting from './components/RoomTypeSetting/index.vue'
|
||||
|
||||
const currentComponent = shallowRef(AccountSetting)
|
||||
const components: any = {
|
||||
AccountSetting,
|
||||
ChannelSetting,
|
||||
RoomTypeSetting,
|
||||
Version,
|
||||
}
|
||||
const onChange = ({ componentName }: any) => currentComponent.value = components[componentName]
|
||||
</script>
|
||||
7
src/renderer/views/stock/index.vue
Normal file
7
src/renderer/views/stock/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>订单</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user