add
42
app.go
|
|
@ -1,42 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"godoos/cmd"
|
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App struct
|
|
||||||
type App struct {
|
|
||||||
ctx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
|
||||||
func NewApp() *App {
|
|
||||||
return &App{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startup is called when the app starts. The context is saved
|
|
||||||
// so we can call the runtime methods
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
|
||||||
a.ctx = ctx
|
|
||||||
go cmd.OsStart()
|
|
||||||
}
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
|
||||||
cmd.OsStop()
|
|
||||||
}
|
|
||||||
func (a *App) OpenDirDialog() string {
|
|
||||||
path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
||||||
Title: "Select Folder",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
runtime.LogErrorf(a.ctx, "Error: %+v\n", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greet returns a greeting for the given name
|
|
||||||
// func (a *App) Greet(name string) string {
|
|
||||||
// return fmt.Sprintf("Hello %s, It's show time!", name)
|
|
||||||
// }
|
|
||||||
78
app/app.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"godoos/cmd"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App struct
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
exDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApp creates a new App application struct
|
||||||
|
func NewApp() *App {
|
||||||
|
return &App{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startup is called when the app starts. The context is saved
|
||||||
|
// so we can call the runtime methods
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
go cmd.OsStart()
|
||||||
|
}
|
||||||
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
cmd.OsStop()
|
||||||
|
}
|
||||||
|
func (a *App) OpenDirDialog() string {
|
||||||
|
path, err := wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Select Folder",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
wruntime.LogErrorf(a.ctx, "Error: %+v\n", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAbsPath(path string) (string, error) {
|
||||||
|
var absPath string
|
||||||
|
var err error
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
absPath = filepath.Clean(path)
|
||||||
|
} else {
|
||||||
|
absPath, err = filepath.Abs(filepath.Join(a.exDir, path))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
absPath = strings.ReplaceAll(absPath, "/", string(os.PathSeparator))
|
||||||
|
//println("GetAbsPath:", absPath)
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RestartApp() error {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exec.Command(name, os.Args[1:]...).Start()
|
||||||
|
wruntime.Quit(a.ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("unsupported OS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetPlatform() string {
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
134
app/download.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cavaliergopher/grab/v3"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) DownloadFile(path string, url string) error {
|
||||||
|
absPath, err := a.GetAbsPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = grab.Get(absPath, url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadStatus struct {
|
||||||
|
resp *grab.Response
|
||||||
|
cancel context.CancelFunc
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Transferred int64 `json:"transferred"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Downloading bool `json:"downloading"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadList []*DownloadStatus
|
||||||
|
|
||||||
|
func existsInDownloadList(path string, url string) bool {
|
||||||
|
for _, ds := range downloadList {
|
||||||
|
if ds.Path == path || ds.Url == url {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PauseDownload(url string) {
|
||||||
|
for _, ds := range downloadList {
|
||||||
|
if ds.Url == url {
|
||||||
|
if ds.cancel != nil {
|
||||||
|
ds.cancel()
|
||||||
|
}
|
||||||
|
ds.resp = nil
|
||||||
|
ds.Downloading = false
|
||||||
|
ds.Speed = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ContinueDownload(url string) {
|
||||||
|
for _, ds := range downloadList {
|
||||||
|
if ds.Url == url {
|
||||||
|
if !ds.Downloading && ds.resp == nil && !ds.Done {
|
||||||
|
ds.Downloading = true
|
||||||
|
|
||||||
|
req, err := grab.NewRequest(ds.Path, ds.Url)
|
||||||
|
if err != nil {
|
||||||
|
ds.Downloading = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// if PauseDownload() is called before the request finished, ds.Downloading will be false
|
||||||
|
// if the user keeps clicking pause and resume, it may result in multiple requests being successfully downloaded at the same time
|
||||||
|
// so we have to create a context and cancel it when PauseDownload() is called
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ds.cancel = cancel
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
resp := grab.DefaultClient.Do(req)
|
||||||
|
|
||||||
|
if resp != nil && resp.HTTPResponse != nil &&
|
||||||
|
resp.HTTPResponse.StatusCode >= 200 && resp.HTTPResponse.StatusCode < 300 {
|
||||||
|
ds.resp = resp
|
||||||
|
} else {
|
||||||
|
ds.Downloading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddToDownloadList(path string, url string) {
|
||||||
|
absPath, err := a.GetAbsPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !existsInDownloadList(absPath, url) {
|
||||||
|
downloadList = append(downloadList, &DownloadStatus{
|
||||||
|
resp: nil,
|
||||||
|
Name: filepath.Base(path),
|
||||||
|
Path: absPath,
|
||||||
|
Url: url,
|
||||||
|
Downloading: false,
|
||||||
|
})
|
||||||
|
a.ContinueDownload(url)
|
||||||
|
} else {
|
||||||
|
a.ContinueDownload(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DownloadLoop() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
for _, ds := range downloadList {
|
||||||
|
if ds.resp != nil {
|
||||||
|
ds.Transferred = ds.resp.BytesComplete()
|
||||||
|
ds.Size = ds.resp.Size()
|
||||||
|
ds.Speed = ds.resp.BytesPerSecond()
|
||||||
|
ds.Progress = 100 * ds.resp.Progress()
|
||||||
|
ds.Downloading = !ds.resp.IsComplete()
|
||||||
|
ds.Done = ds.resp.Progress() == 1
|
||||||
|
if !ds.Downloading {
|
||||||
|
ds.resp = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.EventsEmit(a.ctx, "downloadList", downloadList)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
100
app/update.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/selfupdate"
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProgressReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
total int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *ProgressReader) Read(p []byte) (n int, err error) {
|
||||||
|
n, err = pr.reader.Read(p)
|
||||||
|
pr.err = err
|
||||||
|
pr.total += int64(n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
func (a *App) UpdateApp(url string) (broken bool, err error) {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
pr := &ProgressReader{reader: resp.Body}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(250 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// update progress
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
wruntime.EventsEmit(a.ctx, "updateApp", &DownloadStatus{
|
||||||
|
Name: filepath.Base(url),
|
||||||
|
Path: "",
|
||||||
|
Url: url,
|
||||||
|
Transferred: pr.total,
|
||||||
|
Size: resp.ContentLength,
|
||||||
|
Speed: 0,
|
||||||
|
Progress: 100 * (float64(pr.total) / float64(resp.ContentLength)),
|
||||||
|
Downloading: pr.err == nil && pr.total < resp.ContentLength,
|
||||||
|
Done: pr.total == resp.ContentLength,
|
||||||
|
})
|
||||||
|
if pr.err != nil || pr.total == resp.ContentLength {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var updateFile io.Reader = pr
|
||||||
|
// extract macos binary from zip
|
||||||
|
if strings.HasSuffix(url, ".zip") && runtime.GOOS == "darwin" {
|
||||||
|
zipBytes, err := io.ReadAll(pr)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
file, err := archive.Open("godoos.app/Contents/MacOS/godoos")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
updateFile = file
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply update
|
||||||
|
err = selfupdate.Apply(updateFile, selfupdate.Options{})
|
||||||
|
if err != nil {
|
||||||
|
if rerr := selfupdate.RollbackError(err); rerr != nil {
|
||||||
|
return true, rerr
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// restart app
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
exec.Command(name, os.Args[1:]...).Start()
|
||||||
|
wruntime.Quit(a.ctx)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"godoos/libs"
|
"godoos/libs"
|
||||||
|
"godoos/localchat"
|
||||||
"godoos/progress"
|
"godoos/progress"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -11,7 +12,7 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
const serverAddress = ":56710"
|
const serverAddress = ":56780"
|
||||||
|
|
||||||
var srv *http.Server
|
var srv *http.Server
|
||||||
|
|
||||||
|
|
@ -50,6 +51,9 @@ func OsStart() {
|
||||||
router.HandleFunc("/file/writefile", HandleWriteFile).Methods(http.MethodPost)
|
router.HandleFunc("/file/writefile", HandleWriteFile).Methods(http.MethodPost)
|
||||||
router.HandleFunc("/file/appendfile", HandleAppendFile).Methods(http.MethodPost)
|
router.HandleFunc("/file/appendfile", HandleAppendFile).Methods(http.MethodPost)
|
||||||
router.HandleFunc("/file/watch", WatchHandler).Methods(http.MethodGet)
|
router.HandleFunc("/file/watch", WatchHandler).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/localchat/sse", localchat.SseHandler).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/localchat/message", localchat.HandleMessage).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/localchat/upload", localchat.MultiUploadHandler).Methods(http.MethodPost)
|
||||||
|
|
||||||
go progress.CheckActive(context.Background())
|
go progress.CheckActive(context.Background())
|
||||||
log.Printf("Listening on port: %v", serverAddress)
|
log.Printf("Listening on port: %v", serverAddress)
|
||||||
|
|
|
||||||
15
frontend/components.d.ts
vendored
|
|
@ -14,10 +14,15 @@ declare module 'vue' {
|
||||||
BatteryPop: typeof import('./src/components/taskbar/BatteryPop.vue')['default']
|
BatteryPop: typeof import('./src/components/taskbar/BatteryPop.vue')['default']
|
||||||
Browser: typeof import('./src/components/builtin/Browser.vue')['default']
|
Browser: typeof import('./src/components/builtin/Browser.vue')['default']
|
||||||
Calendar: typeof import('./src/components/builtin/Calendar.vue')['default']
|
Calendar: typeof import('./src/components/builtin/Calendar.vue')['default']
|
||||||
|
Chat: typeof import('./src/components/localchat/Chat.vue')['default']
|
||||||
|
ChatContent: typeof import('./src/components/localchat/ChatContent.vue')['default']
|
||||||
|
ChatDomain: typeof import('./src/components/localchat/ChatDomain.vue')['default']
|
||||||
|
ChatEditor: typeof import('./src/components/localchat/ChatEditor.vue')['default']
|
||||||
|
ChatFoot: typeof import('./src/components/localchat/ChatFoot.vue')['default']
|
||||||
|
ChatNav: typeof import('./src/components/localchat/ChatNav.vue')['default']
|
||||||
CloseButton: typeof import('./src/components/ui/CloseButton.vue')['default']
|
CloseButton: typeof import('./src/components/ui/CloseButton.vue')['default']
|
||||||
CloseDesktop: typeof import('./src/components/desktop/CloseDesktop.vue')['default']
|
CloseDesktop: typeof import('./src/components/desktop/CloseDesktop.vue')['default']
|
||||||
ColorPicker: typeof import('./src/components/setting/ColorPicker.vue')['default']
|
ColorPicker: typeof import('./src/components/setting/ColorPicker.vue')['default']
|
||||||
Compony: typeof import('./src/components/install/compony.vue')['default']
|
|
||||||
Computer: typeof import('./src/components/computer/Computer.vue')['default']
|
Computer: typeof import('./src/components/computer/Computer.vue')['default']
|
||||||
ComputerNavBar: typeof import('./src/components/computer/ComputerNavBar.vue')['default']
|
ComputerNavBar: typeof import('./src/components/computer/ComputerNavBar.vue')['default']
|
||||||
ContextMenu: typeof import('./src/components/builtin/ContextMenu.vue')['default']
|
ContextMenu: typeof import('./src/components/builtin/ContextMenu.vue')['default']
|
||||||
|
|
@ -33,18 +38,23 @@ declare module 'vue' {
|
||||||
DialogTemp: typeof import('./src/components/window/DialogTemp.vue')['default']
|
DialogTemp: typeof import('./src/components/window/DialogTemp.vue')['default']
|
||||||
EditFileName: typeof import('./src/components/builtin/EditFileName.vue')['default']
|
EditFileName: typeof import('./src/components/builtin/EditFileName.vue')['default']
|
||||||
EditType: typeof import('./src/components/builtin/EditType.vue')['default']
|
EditType: typeof import('./src/components/builtin/EditType.vue')['default']
|
||||||
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||||
ElText: typeof import('element-plus/es')['ElText']
|
ElText: typeof import('element-plus/es')['ElText']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
Error: typeof import('./src/components/taskbar/Error.vue')['default']
|
Error: typeof import('./src/components/taskbar/Error.vue')['default']
|
||||||
|
|
@ -60,12 +70,10 @@ declare module 'vue' {
|
||||||
Install: typeof import('./src/components/install/Install.vue')['default']
|
Install: typeof import('./src/components/install/Install.vue')['default']
|
||||||
InstallCompony: typeof import('./src/components/install/InstallCompony.vue')['default']
|
InstallCompony: typeof import('./src/components/install/InstallCompony.vue')['default']
|
||||||
InstallMember: typeof import('./src/components/install/InstallMember.vue')['default']
|
InstallMember: typeof import('./src/components/install/InstallMember.vue')['default']
|
||||||
INstallMember: typeof import('./src/components/install/INstallMember.vue')['default']
|
|
||||||
InstallPerson: typeof import('./src/components/install/InstallPerson.vue')['default']
|
InstallPerson: typeof import('./src/components/install/InstallPerson.vue')['default']
|
||||||
LockDesktop: typeof import('./src/components/desktop/LockDesktop.vue')['default']
|
LockDesktop: typeof import('./src/components/desktop/LockDesktop.vue')['default']
|
||||||
Magnet: typeof import('./src/components/taskbar/Magnet.vue')['default']
|
Magnet: typeof import('./src/components/taskbar/Magnet.vue')['default']
|
||||||
MarkDown: typeof import('./src/components/builtin/MarkDown.vue')['default']
|
MarkDown: typeof import('./src/components/builtin/MarkDown.vue')['default']
|
||||||
Member: typeof import('./src/components/install/member.vue')['default']
|
|
||||||
MenuBar: typeof import('./src/components/window/MenuBar.vue')['default']
|
MenuBar: typeof import('./src/components/window/MenuBar.vue')['default']
|
||||||
MenuList: typeof import('./src/components/taskbar/MenuList.vue')['default']
|
MenuList: typeof import('./src/components/taskbar/MenuList.vue')['default']
|
||||||
MessageCenterPop: typeof import('./src/components/taskbar/MessageCenterPop.vue')['default']
|
MessageCenterPop: typeof import('./src/components/taskbar/MessageCenterPop.vue')['default']
|
||||||
|
|
@ -80,7 +88,6 @@ declare module 'vue' {
|
||||||
OpenWiteDialog: typeof import('./src/components/builtin/OpenWiteDialog.vue')['default']
|
OpenWiteDialog: typeof import('./src/components/builtin/OpenWiteDialog.vue')['default']
|
||||||
OsImage: typeof import('./src/components/builtin/OsImage.vue')['default']
|
OsImage: typeof import('./src/components/builtin/OsImage.vue')['default']
|
||||||
PdfViewer: typeof import('./src/components/builtin/PdfViewer.vue')['default']
|
PdfViewer: typeof import('./src/components/builtin/PdfViewer.vue')['default']
|
||||||
Person: typeof import('./src/components/install/person.vue')['default']
|
|
||||||
PictureStore: typeof import('./src/components/builtin/PictureStore.vue')['default']
|
PictureStore: typeof import('./src/components/builtin/PictureStore.vue')['default']
|
||||||
QuickLink: typeof import('./src/components/computer/QuickLink.vue')['default']
|
QuickLink: typeof import('./src/components/computer/QuickLink.vue')['default']
|
||||||
RectChosen: typeof import('./src/components/builtin/RectChosen.vue')['default']
|
RectChosen: typeof import('./src/components/builtin/RectChosen.vue')['default']
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"@liripeng/vue-audio-player": "^1.6.2",
|
"@liripeng/vue-audio-player": "^1.6.2",
|
||||||
|
"dexie": "^4.0.8",
|
||||||
"element-plus": "^2.7.7",
|
"element-plus": "^2.7.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
46b0e0f4287bf8042cfe0a66114c4afc
|
1fa97678edcc780911c3d714233e97d2
|
||||||
2
frontend/public/image/chat/emoji.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1656439603095" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3879" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff2?t=1630033759944") format("woff2"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.woff?t=1630033759944") format("woff"), url("//at.alicdn.com/t/font_1031158_u69w8yhxdu.ttf?t=1630033759944") format("truetype"); }
|
||||||
|
</style></defs><path d="M510.944 960c-247.04 0-448-200.96-448-448s200.992-448 448-448c247.008 0 448 200.96 448 448S757.984 960 510.944 960zM510.944 128c-211.744 0-384 172.256-384 384 0 211.744 172.256 384 384 384 211.744 0 384-172.256 384-384C894.944 300.256 722.688 128 510.944 128z" p-id="3880" fill="#303133"></path><path d="M512 773.344c-89.184 0-171.904-40.32-226.912-110.624-10.88-13.92-8.448-34.016 5.472-44.896 13.888-10.912 34.016-8.48 44.928 5.472 42.784 54.688 107.136 86.048 176.512 86.048 70.112 0 134.88-31.904 177.664-87.552 10.784-14.016 30.848-16.672 44.864-5.888 14.016 10.784 16.672 30.88 5.888 44.864C685.408 732.32 602.144 773.344 512 773.344z" p-id="3881" fill="#303133"></path><path d="M368 515.2c-26.528 0-48-21.472-48-48l0-64c0-26.528 21.472-48 48-48s48 21.472 48 48l0 64C416 493.696 394.496 515.2 368 515.2z" p-id="3882" fill="#303133"></path><path d="M656 515.2c-26.496 0-48-21.472-48-48l0-64c0-26.528 21.504-48 48-48s48 21.472 48 48l0 64C704 493.696 682.496 515.2 656 515.2z" p-id="3883" fill="#303133"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/image/chat/emoji/666.gif
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/public/image/chat/emoji/发怒.gif
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
frontend/public/image/chat/emoji/可爱狗.gif
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
frontend/public/image/chat/emoji/吃屎.gif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/image/chat/emoji/吐了.gif
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
frontend/public/image/chat/emoji/吐槽.gif
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
frontend/public/image/chat/emoji/哭笑.gif
Normal file
|
After Width: | Height: | Size: 615 KiB |
BIN
frontend/public/image/chat/emoji/嘲笑.gif
Normal file
|
After Width: | Height: | Size: 950 KiB |
BIN
frontend/public/image/chat/emoji/大哭.gif
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/image/chat/emoji/大眼.gif
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/image/chat/emoji/天使.gif
Normal file
|
After Width: | Height: | Size: 772 KiB |
BIN
frontend/public/image/chat/emoji/好吃.gif
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
frontend/public/image/chat/emoji/委屈.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/image/chat/emoji/委屈巴巴.gif
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
frontend/public/image/chat/emoji/小丑.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/image/chat/emoji/崇拜.gif
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
frontend/public/image/chat/emoji/思考.gif
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
frontend/public/image/chat/emoji/惊吓.gif
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
frontend/public/image/chat/emoji/想吐.gif
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
frontend/public/image/chat/emoji/想哭.gif
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
frontend/public/image/chat/emoji/拍打.gif
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
frontend/public/image/chat/emoji/斜眼笑.gif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/public/image/chat/emoji/斜笑.gif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/image/chat/emoji/比耶.gif
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/public/image/chat/emoji/比酷.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/image/chat/emoji/流汗.gif
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
frontend/public/image/chat/emoji/疑问.gif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/image/chat/emoji/调皮.gif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/image/chat/emoji/超级崇拜.gif
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/public/image/chat/emoji/酷笑.gif
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
frontend/public/image/chat/emoji/露牙笑.gif
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/image/chat/notice.mp3
Normal file
|
|
@ -1,24 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { System } from "@/system/index.ts";
|
import { System } from "@/system/index.ts";
|
||||||
import { onMounted, computed } from "vue";
|
import { onMounted } from "vue";
|
||||||
// 使用ref和computed改进状态管理
|
|
||||||
const isInstall: any = computed(() => {
|
|
||||||
const item = localStorage.getItem("isGodoOSInstall");
|
|
||||||
// 显式地进行类型检查和转换
|
|
||||||
return item === "true";
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
new System();
|
new System();
|
||||||
});
|
});
|
||||||
// router.beforeEach((to, _, next) => {
|
|
||||||
// console.log(to.name);
|
|
||||||
// if (!isInstall.value && to.name !== "install") {
|
|
||||||
// next("/install");
|
|
||||||
// } else {
|
|
||||||
// next();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
125
frontend/src/assets/emoji.json
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
[{
|
||||||
|
"title": "[666]",
|
||||||
|
"icon": "/image/chat/emoji/666.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[比酷]",
|
||||||
|
"icon": "/image/chat/emoji/比酷.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[比耶]",
|
||||||
|
"icon": "/image/chat/emoji/比耶.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[超级崇拜]",
|
||||||
|
"icon": "/image/chat/emoji/超级崇拜.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[嘲笑]",
|
||||||
|
"icon": "/image/chat/emoji/嘲笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[吃屎]",
|
||||||
|
"icon": "/image/chat/emoji/吃屎.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[崇拜]",
|
||||||
|
"icon": "/image/chat/emoji/崇拜.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[大哭]",
|
||||||
|
"icon": "/image/chat/emoji/大哭.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[大眼]",
|
||||||
|
"icon": "/image/chat/emoji/大眼.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[调皮]",
|
||||||
|
"icon": "/image/chat/emoji/调皮.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[发怒]",
|
||||||
|
"icon": "/image/chat/emoji/发怒.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[好吃]",
|
||||||
|
"icon": "/image/chat/emoji/好吃.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[惊吓]",
|
||||||
|
"icon": "/image/chat/emoji/惊吓.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[可爱狗]",
|
||||||
|
"icon": "/image/chat/emoji/可爱狗.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[哭笑]",
|
||||||
|
"icon": "/image/chat/emoji/哭笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[酷笑]",
|
||||||
|
"icon": "/image/chat/emoji/酷笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[流汗]",
|
||||||
|
"icon": "/image/chat/emoji/流汗.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[露牙笑]",
|
||||||
|
"icon": "/image/chat/emoji/露牙笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[拍打]",
|
||||||
|
"icon": "/image/chat/emoji/拍打.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[思考]",
|
||||||
|
"icon": "/image/chat/emoji/思考.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[天使]",
|
||||||
|
"icon": "/image/chat/emoji/天使.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[吐槽]",
|
||||||
|
"icon": "/image/chat/emoji/吐槽.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[吐了]",
|
||||||
|
"icon": "/image/chat/emoji/吐了.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[委屈]",
|
||||||
|
"icon": "/image/chat/emoji/委屈.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[委屈巴巴]",
|
||||||
|
"icon": "/image/chat/emoji/委屈巴巴.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[想哭]",
|
||||||
|
"icon": "/image/chat/emoji/想哭.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[想吐]",
|
||||||
|
"icon": "/image/chat/emoji/想吐.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[小丑]",
|
||||||
|
"icon": "/image/chat/emoji/小丑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[斜笑]",
|
||||||
|
"icon": "/image/chat/emoji/斜笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[斜眼笑]",
|
||||||
|
"icon": "/image/chat/emoji/斜眼笑.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[疑问]",
|
||||||
|
"icon": "/image/chat/emoji/疑问.gif"
|
||||||
|
}
|
||||||
|
]
|
||||||
75
frontend/src/components/localchat/Chat.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<template>
|
||||||
|
<el-row justify="space-between">
|
||||||
|
<el-col :span="2">
|
||||||
|
<chat-nav />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<chat-domain />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="16">
|
||||||
|
<chat-content />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { getSystemConfig } from "@/system/config";
|
||||||
|
const store = useLocalChatStore();
|
||||||
|
const config = getSystemConfig();
|
||||||
|
|
||||||
|
let source:any;
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.init()
|
||||||
|
init()
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (source) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function init() {
|
||||||
|
if (typeof EventSource === "undefined") {
|
||||||
|
ElMessage.error("您的浏览器不支持SSE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sseUrl = config.apiUrl + "/localchat/sse";
|
||||||
|
source = new EventSource(sseUrl);
|
||||||
|
// 当接收到消息时触发
|
||||||
|
source.onmessage = async function (event:any) {
|
||||||
|
//console.log("has message!");
|
||||||
|
const eventData = event.data; // 先保存原始数据
|
||||||
|
const jsonData = JSON.parse(eventData); // 解析数据
|
||||||
|
//console.log(jsonData);
|
||||||
|
if (jsonData.type == "user_list") {
|
||||||
|
//store.userList = jsonData;
|
||||||
|
store.setUserList(jsonData.content);
|
||||||
|
//await nextTick();
|
||||||
|
}
|
||||||
|
if(jsonData.type == 'text'){
|
||||||
|
store.addText(jsonData);
|
||||||
|
}
|
||||||
|
if(jsonData.type == 'file'){
|
||||||
|
store.addFile(jsonData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 当与服务器的连接打开时触发
|
||||||
|
source.onopen = function () {
|
||||||
|
console.log("Connection opened.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当与服务器的连接关闭时触发
|
||||||
|
source.onerror = function () {
|
||||||
|
console.log("Connection closed.");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
248
frontend/src/components/localchat/ChatContent.vue
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, inject, ref, nextTick, watch } from "vue";
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat";
|
||||||
|
import { formatChatTime } from "@/util/common";
|
||||||
|
import Vditor from "vditor";
|
||||||
|
import "vditor/dist/index.css";
|
||||||
|
import { ElScrollbar } from "element-plus";
|
||||||
|
import { System } from "@/system";
|
||||||
|
const sys:any = inject<System>("system");
|
||||||
|
const store = useLocalChatStore();
|
||||||
|
const messageContainerRef = ref<InstanceType<typeof ElScrollbar>>();
|
||||||
|
const messageInnerRef = ref<HTMLDivElement>();
|
||||||
|
let isScrool = false;
|
||||||
|
onMounted(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messageContainerRef && messageContainerRef.value) {
|
||||||
|
// messageContainerRef.value!.setScrollTop(
|
||||||
|
// messageInnerRef.value!.clientHeight
|
||||||
|
// );
|
||||||
|
messageContainerRef.value.setScrollTop(messageInnerRef.value!.clientHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => store.msgList,
|
||||||
|
(_) => {
|
||||||
|
if(!isScrool){
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
function replaceIconTags(text:any) {
|
||||||
|
// 定义正则表达式,匹配 {*任意内容*} 的格式
|
||||||
|
text = Vditor.md2html(text);
|
||||||
|
const regex = /\{\-(.*?)\-\}/g;
|
||||||
|
|
||||||
|
// 使用正则表达式的replace方法进行替换
|
||||||
|
const replacedText = text.replace(regex, (_:any, p1:string) => {
|
||||||
|
// p1 是匹配到的内容部分,这里直接构造图片标签
|
||||||
|
return `<img src='/image/chat/emoji/${p1}.gif' style='width:30px;height:30px;' />`;
|
||||||
|
//return ``
|
||||||
|
});
|
||||||
|
|
||||||
|
return replacedText;
|
||||||
|
}
|
||||||
|
async function scroll({ scrollTop }: { scrollTop: number }) {
|
||||||
|
if (store.msgList.length + 1 > store.pageSize && scrollTop < 1) {
|
||||||
|
isScrool = true;
|
||||||
|
await store.moreMsgList();
|
||||||
|
isScrool = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chatContentContainer" v-if="store.chatTargetId > 0">
|
||||||
|
<div class="message-area">
|
||||||
|
<el-scrollbar
|
||||||
|
max-height="100%"
|
||||||
|
class="scrollbar-container"
|
||||||
|
@scroll="scroll"
|
||||||
|
ref="messageContainerRef">
|
||||||
|
<div ref="messageInnerRef" class="message-wrap">
|
||||||
|
<div v-for="(item, index) in store.msgList" :key="index" :class="['message-block', item.isMe ? 'mine' : 'theirs']">
|
||||||
|
<div class="avatar-container">
|
||||||
|
<div class="icon-container">
|
||||||
|
<el-icon><component :is="item.isMe ? 'UserFilled' : 'Place'"/></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'text'"
|
||||||
|
v-html="replaceIconTags(item.content)"
|
||||||
|
class="message-content">
|
||||||
|
</div>
|
||||||
|
<div v-if="item.type === 'file'">
|
||||||
|
<div class="file-bubble">
|
||||||
|
<div class="file-content" v-for="el in item.content" @click="sys.openFile(el.path)">
|
||||||
|
<div class="file-icon"><FileIcon :file="el" /></div>
|
||||||
|
<div class="file-name">{{el.name}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timestamp text-grey">{{ formatChatTime(item.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
<ChatFoot class="mt-20px"></ChatFoot>
|
||||||
|
</div>
|
||||||
|
<div class="no-message-container" v-else>
|
||||||
|
<Vue3Lottie animationLink="/bot/localchat.json" :height="300" :width="300" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$win10-blue: #0078d7;
|
||||||
|
$win10-light-blue: #c7e8ff;
|
||||||
|
$win10-grey: #afafaf;
|
||||||
|
$win10-light-grey: #f2f2f2;
|
||||||
|
|
||||||
|
.chatContentContainer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-area {
|
||||||
|
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-container {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrap {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-block {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: flex-end; // 保持底部对齐
|
||||||
|
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid $win10-grey;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
background: $win10-light-grey;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $win10-grey;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $win10-grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
// 添加这一行来确保'mine'类的消息整体靠右
|
||||||
|
justify-content: flex-end;
|
||||||
|
.content {
|
||||||
|
// 由于'mine'消息块整体靠右,内容区域不需要特别处理对齐
|
||||||
|
// justify-content: flex-end; 可以移除,因为它会与外部的justify-content冲突
|
||||||
|
align-items: flex-end; // 保持底部对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
background-color: $win10-blue;
|
||||||
|
color: $win10-light-blue;
|
||||||
|
border-radius: 12px 2px 2px 2px; // 保持原有样式
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
order: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-top:-20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theirs {
|
||||||
|
.content {
|
||||||
|
// 使对方的消息内容靠左对齐
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
border-radius: 2px 12px 2px 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.no-message-container {
|
||||||
|
height: 100%;
|
||||||
|
margin: 120px auto;
|
||||||
|
}
|
||||||
|
.file-bubble {
|
||||||
|
background-color: #f0f0f0; /* 背景色,可以根据需要调整 */
|
||||||
|
border-radius: 10px; /* 圆角,让框看起来更柔和 */
|
||||||
|
padding: 10px; /* 内边距,给内容一些空间 */
|
||||||
|
margin-bottom: 10px; /* 气泡间的外边距,使它们看起来不紧凑 */
|
||||||
|
max-width: 100%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 添加阴影效果,增强立体感 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height:36px;
|
||||||
|
line-height:36px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.file-content:hover {
|
||||||
|
background-color: #e0e0e0; /* 改变背景色,悬停时更浅或更深,根据设计调整 */
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* 增强阴影效果,使气泡在悬停时更加突出 */
|
||||||
|
transition: all 0.3s ease; /* 添加过渡效果,使变化平滑 */
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
padding-left: 5px;
|
||||||
|
/* 可选:限制文件名行数,如果需要 */
|
||||||
|
/* max-height: 2em; */
|
||||||
|
/* display: -webkit-box; */
|
||||||
|
/* -webkit-line-clamp: 2; */
|
||||||
|
/* -webkit-box-orient: vertical; */
|
||||||
|
/* overflow: hidden; */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
199
frontend/src/components/localchat/ChatDomain.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
<template>
|
||||||
|
<div class="win11-msg-container">
|
||||||
|
<el-scrollbar>
|
||||||
|
<div v-if="store.navId < 2" class="user-list-area">
|
||||||
|
<el-row
|
||||||
|
class="user-list"
|
||||||
|
justify="space-around"
|
||||||
|
v-for="(msg, key) in store.contentList"
|
||||||
|
:key="key"
|
||||||
|
v-if="store.contentList.length > 0"
|
||||||
|
>
|
||||||
|
<!-- <el-col :span="5" class="avatar-col">
|
||||||
|
<el-icon :size="22" class="avatar">
|
||||||
|
<Place />
|
||||||
|
</el-icon>
|
||||||
|
</el-col> -->
|
||||||
|
<el-col :span="22" :class="msg.targetIp == store.chatTargetIp ? 'user-item active' : 'user-item'" @click="store.setChatId(msg.targetIp)">
|
||||||
|
<span class="username">
|
||||||
|
|
||||||
|
<el-icon :size="12">
|
||||||
|
<Place />
|
||||||
|
</el-icon>
|
||||||
|
{{ msg.reciperInfo.hostname }}
|
||||||
|
</span>
|
||||||
|
<span class="msg">{{ msg.content }}</span>
|
||||||
|
|
||||||
|
<span class="userip" v-if="msg.readNum > 0">
|
||||||
|
<el-badge :value="msg.readNum" :offset="[-3,8]">
|
||||||
|
{{ formatChatTime(msg.createdAt) }}</el-badge>
|
||||||
|
</span>
|
||||||
|
<span class="userip" v-else>
|
||||||
|
{{ formatChatTime(msg.createdAt) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-empty v-else :image-size="100" description="消息列表为空" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="user-list-area">
|
||||||
|
<el-row
|
||||||
|
justify="start">
|
||||||
|
<el-icon :size="18" @click="store.refreshUserList">
|
||||||
|
<RefreshRight />
|
||||||
|
</el-icon>
|
||||||
|
</el-row>
|
||||||
|
<el-row
|
||||||
|
class="user-list"
|
||||||
|
justify="space-around"
|
||||||
|
v-for="(user, key) in store.userList"
|
||||||
|
:key="key"
|
||||||
|
v-if="store.userList.length > 0"
|
||||||
|
>
|
||||||
|
<el-col :span="5" class="avatar-col">
|
||||||
|
<el-icon :size="22" class="avatar">
|
||||||
|
<Monitor />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon v-if="user.isOnline" :size="16" class="status-icon online">
|
||||||
|
<CircleCheck />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon v-else :size="16" class="status-icon offline">
|
||||||
|
<Warning />
|
||||||
|
</el-icon>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="19" class="user-item" @click="store.setChatId(user.ip)">
|
||||||
|
<span class="username">{{ user.hostname }}</span>
|
||||||
|
<span class="userip">{{ user.ip }}</span>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-empty v-else :image-size="100" description="好友列表为空" />
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat";
|
||||||
|
import { formatChatTime } from "@/util/common";
|
||||||
|
|
||||||
|
const store = useLocalChatStore();
|
||||||
|
</script>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.win11-msg-container {
|
||||||
|
height: 100vh; /* 假设顶部有100px的导航栏 */
|
||||||
|
width: 100%;
|
||||||
|
border-right: 1px solid rgba(238, 238, 238, 0.5); /* 更柔和的边框 */
|
||||||
|
border-top: 1px solid rgba(238, 238, 238, 0.5);
|
||||||
|
border-radius: 0 12px 0 0; /* Win11风格的圆角 */
|
||||||
|
background-color: #f8f8f8; /* 使用更亮的淡灰色背景 */
|
||||||
|
overflow: hidden; /* 清除滚动条溢出 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-scrollbar__wrap {
|
||||||
|
padding: 16px; /* 内容区域的内边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
&.self {
|
||||||
|
justify-content: flex-end; /* 自身消息右对齐 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px; /* 圆角 */
|
||||||
|
max-width: 75%;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
&.bubble--right {
|
||||||
|
background-color: #e8eaed; /* 更接近Win11的发送方消息背景色 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.user-list-area {
|
||||||
|
width: 94%;
|
||||||
|
margin: 3%;
|
||||||
|
}
|
||||||
|
.user-list {
|
||||||
|
background-color: #f8f8f8; // 淡灰色背景,与容器区分
|
||||||
|
border-radius: 8px; // 圆角边缘,更柔和的外观
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); // 温和的阴影效果,增加深度感
|
||||||
|
.avatar-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center; // 确保图标垂直居中
|
||||||
|
}
|
||||||
|
.active{
|
||||||
|
background-color: #e8eaed;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column; /* 改变为列方向布局 */
|
||||||
|
gap: 4px; /* 行间间距 */
|
||||||
|
padding: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%; /* 头像圆角 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
text-align: left; // 设置username左对齐
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
width:95%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
text-align: left; // 设置username左对齐
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
overflow: hidden;
|
||||||
|
width:95%;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.userip {
|
||||||
|
text-align: right; // 设置userip右对齐
|
||||||
|
margin-left: auto; // 这将使userip在容器内右对齐
|
||||||
|
font-size: 11px;
|
||||||
|
padding-right: 5px;
|
||||||
|
color: #666;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.status-icon {
|
||||||
|
margin-left: 2px; // 与头像保持一定距离
|
||||||
|
margin-top:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online {
|
||||||
|
color: green; // 在线图标颜色
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline {
|
||||||
|
color: red; // 离线图标颜色
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态调整 */
|
||||||
|
.el-empty {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
frontend/src/components/localchat/ChatEditor.vue
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<!-- 聊天编辑区域 -->
|
||||||
|
<div
|
||||||
|
class="edit-box"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
class="message-input"
|
||||||
|
@keydown.enter="keyDown($event)"
|
||||||
|
v-model="store.sendInfo" />
|
||||||
|
<el-tooltip placement="top" content="按enter键发送,按ctrl+enter键换行">
|
||||||
|
<el-icon :size="22" class="win11-chat-send-button" @click="store.sendMsg()">
|
||||||
|
<Promotion />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat"
|
||||||
|
import { notifyError } from "@/util/msg";
|
||||||
|
|
||||||
|
const store = useLocalChatStore()
|
||||||
|
// 按下回车键
|
||||||
|
function keyDown(event: any) {
|
||||||
|
if (event.ctrlKey && event.keyCode === 13) {
|
||||||
|
store.sendInfo = store.sendInfo + "\n"
|
||||||
|
|
||||||
|
} else if (event.keyCode === 13) {
|
||||||
|
event.preventDefault() // 阻止浏览器默认换行操作
|
||||||
|
send()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleDrop = (event:any) => {
|
||||||
|
const frompathArrStr = event?.dataTransfer?.getData('frompath');
|
||||||
|
event.preventDefault()
|
||||||
|
const files = JSON.parse(frompathArrStr) as string[];
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
// 处理拖放的文件,例如上传
|
||||||
|
console.log('Files dropped:', files);
|
||||||
|
store.uploadFile(files)
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function send(){
|
||||||
|
if(!store.hostInfo || !store.hostInfo.ip){
|
||||||
|
notifyError("Please wait for a moment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.sendMsg()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-box {
|
||||||
|
position: relative; /* 为容器添加相对定位,以便子元素可以相对于它进行定位 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
resize: none; /* 禁止调整输入框大小,保持布局稳定 */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.win11-chat-send-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5px;
|
||||||
|
right: 5px;
|
||||||
|
width: 30px; /* 缩小宽度 */
|
||||||
|
height: 30px; /* 减小高度 */
|
||||||
|
border-radius: 50%; /* 较小的圆角 */
|
||||||
|
background-color: #E8F0FE; /* 浅蓝色,符合Win11的轻量风格 */
|
||||||
|
color: #0078D4; /* 使用Win11的强调色作为文字颜色 */
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid #B3D4FC; /* 添加边框,保持简洁风格 */
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); /* 轻微阴影 */
|
||||||
|
transition: all 0.2s ease; /* 快速过渡效果 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.win11-chat-send-button:hover {
|
||||||
|
background-color: #D1E4FF; /* 悬浮时颜色略深,保持浅色调 */
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 稍微增强阴影 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.win11-chat-send-button:active {
|
||||||
|
background-color: #B3D4FC; /* 按下时颜色更深,但依然保持清新 */
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0.1); /* 回复初始阴影 */
|
||||||
|
transform: translateY(1px); /* 微小下移,模拟按下 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
frontend/src/components/localchat/ChatFoot.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<template>
|
||||||
|
<footer class="footer-container">
|
||||||
|
<!--工具栏-->
|
||||||
|
<el-row type="flex" class="toolbar">
|
||||||
|
<el-popover
|
||||||
|
placement="top"
|
||||||
|
popper-class="chat-icon-popover"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<div class="emoji-button">
|
||||||
|
<img
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
class="emoji-image"
|
||||||
|
src="/image/chat/emoji.svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-scrollbar class="emoji-scroll">
|
||||||
|
<ul class="emoji-list-container">
|
||||||
|
<li
|
||||||
|
v-for="item in store.emojiList"
|
||||||
|
:key="item.title"
|
||||||
|
class="p-5px list-none hover:animate-heart-beat animate-count-animated animate-duration-1s cursor-pointer"
|
||||||
|
:title="item.title"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
:src="item.icon"
|
||||||
|
@click="selectIcon(item.icon)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-popover>
|
||||||
|
<div class="upload-button" @click="selectImg()">
|
||||||
|
<el-icon :size="22">
|
||||||
|
<Picture />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="upload-button" @click="selectFile()">
|
||||||
|
<el-icon :size="22">
|
||||||
|
<Link />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="upload-button" @click="store.clearMsg()">
|
||||||
|
<el-icon :size="22">
|
||||||
|
<Delete />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</el-row>
|
||||||
|
<ChatEditor></ChatEditor>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat";
|
||||||
|
import { useChooseStore } from "@/stores/choose";
|
||||||
|
|
||||||
|
import { toRaw, watch } from "vue";
|
||||||
|
const store = useLocalChatStore();
|
||||||
|
const choose = useChooseStore();
|
||||||
|
//const editor = ref(null)
|
||||||
|
const imgExt = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"];
|
||||||
|
|
||||||
|
// 选择表情
|
||||||
|
function selectIcon(icon: string) {
|
||||||
|
store.sendInfo +=
|
||||||
|
"{-" + icon.replace("/image/chat/emoji/", "").replace(".gif", "") + "-}";
|
||||||
|
}
|
||||||
|
function selectImg() {
|
||||||
|
choose.select("选择图片", imgExt);
|
||||||
|
}
|
||||||
|
function selectFile() {
|
||||||
|
choose.select("选择文件", "*");
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => choose.path,
|
||||||
|
(newVal, _) => {
|
||||||
|
//console.log("userList 变化了:", newVal);
|
||||||
|
const paths = toRaw(newVal)
|
||||||
|
if(paths.length > 0){
|
||||||
|
store.uploadFile(paths).then(() => {
|
||||||
|
choose.path = []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
},
|
||||||
|
{ deep: true } // 添加deep: true以深度监听数组或对象内部的变化
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.footer-container {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.emoji-button {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-image {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-scroll {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-list {
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-list-item {
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
animation-duration: 1s;
|
||||||
|
&.hover:animate-heart-beat {
|
||||||
|
animation-name: heart-beat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.emoji-list-container {
|
||||||
|
margin: 0; /* 对应 m0 */
|
||||||
|
padding: 0; /* 对应 p0 */
|
||||||
|
display: flex; /* 对应 flex */
|
||||||
|
flex-wrap: wrap; /* 对应 flex-wrap */
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
margin-left: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-editor {
|
||||||
|
/* Assuming this class already exists or you define it elsewhere */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
frontend/src/components/localchat/ChatNav.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<el-space direction="vertical" :size="20" class="win11-chat-nav">
|
||||||
|
<div class="nav-item" v-for="item in store.navList" :key="item.index">
|
||||||
|
<el-button
|
||||||
|
:icon="item.icon"
|
||||||
|
:class="store.navId === item.index ? 'active' : ''"
|
||||||
|
dark
|
||||||
|
circle
|
||||||
|
@click="store.handleSelect(item.index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocalChatStore } from "@/stores/localchat";
|
||||||
|
const store = useLocalChatStore();
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.win11-chat-nav {
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f8f8f8; /* 使用更亮的淡灰色,更接近Win11的背景色 */
|
||||||
|
border-right: 1px solid rgba(230, 230, 230, 0.5); /* 更浅的边框颜色 */
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* 添加全局字体样式以匹配Win11 */
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win11-chat-nav .nav-item {
|
||||||
|
/* 假定每个按钮的基类 */
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 50%; /* 圆角 */
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 渐变阴影 */
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f0f0f0; /* 鼠标悬停时的轻微颜色变化 */
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
/* 加强背景色对比,使用Win11的强调色或品牌色 */
|
||||||
|
background-color: #4579a1; /* 示例颜色,可根据设计调整 */
|
||||||
|
color: white; /* 文字颜色反转,确保可读性 */
|
||||||
|
|
||||||
|
/* 增加外边框以进一步区分 */
|
||||||
|
border: 2px solid #005a9c; /* 较深的强调色作为边框 */
|
||||||
|
border-radius: 50%;
|
||||||
|
/* 内发光效果,让按钮看起来更‘活跃’ */
|
||||||
|
box-shadow: 0 5px 8px rgba(0, 120, 212, 0.5) inset;
|
||||||
|
|
||||||
|
/* 微动效,当状态改变时给予用户反馈 */
|
||||||
|
transform: scale(1.02);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.2, 0.4, 0.6, 1);
|
||||||
|
|
||||||
|
/* 确保文字在按下时不会因按钮尺寸变化而偏移 */
|
||||||
|
transition-property: background-color, box-shadow, transform, color;
|
||||||
|
|
||||||
|
/* 如果按钮包含图标,可以考虑为图标也添加强调效果,例如改变颜色 */
|
||||||
|
.el-icon {
|
||||||
|
color: inherit; /* 或指定特定强调色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 为了平滑的过渡,确保所有相关属性都被包含在transition中 */
|
||||||
|
}
|
||||||
|
/* 考虑到按钮是圆形且使用了 `circle` 属性,确保图标和背景颜色调整得当 */
|
||||||
|
&.active .el-button {
|
||||||
|
background-color: transparent !important; /* 确保背景色不影响图标颜色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标颜色调整,确保在active状态下足够突出 */
|
||||||
|
&.active .el-icon {
|
||||||
|
color: #ffffff; /* 确保图标颜色与背景对比鲜明 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 非活动状态的悬停效果,保持与.active状态的区分 */
|
||||||
|
.nav-item:hover:not(.active) {
|
||||||
|
/* 调整以与.active状态区分,例如使用较浅的颜色 */
|
||||||
|
background-color: #eaeaea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
frontend/src/stores/db.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import Dexie from 'dexie'
|
||||||
|
|
||||||
|
export type ChatTable = 'chatuser' | 'chatmsg' | 'chatfile'
|
||||||
|
|
||||||
|
export const dbInit:any = new Dexie('GodoOSDatabase');
|
||||||
|
dbInit.version(1).stores({
|
||||||
|
chatuser:'++id,ip,hostname,userName,avatar,mobile,nickName,isOnline,updatedAt,createdAt',
|
||||||
|
chatmsg:'++id,targetId,targetIp,senderInfo,reciperInfo,content,type,status,isRead,isMe,readAt,createdAt',
|
||||||
|
chatfile:'++id,msgId,fileName,fileSize,fileType,fileUrl,filePath,fileExt'
|
||||||
|
});
|
||||||
|
export const db = {
|
||||||
|
|
||||||
|
async getMaxId(tableName: ChatTable) {
|
||||||
|
const data = await dbInit[tableName].orderBy('id').reverse().first()
|
||||||
|
if (!data) {
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
return data.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getInsertId(tableName: ChatTable) {
|
||||||
|
const id: any = await this.getMaxId(tableName)
|
||||||
|
return id + 1
|
||||||
|
},
|
||||||
|
async getPage(tableName: ChatTable, page?: number, size?: number) {
|
||||||
|
page = (!page || page < 1) ? 1 : page
|
||||||
|
size = size ? size : 10
|
||||||
|
const offset = (page - 1) * size
|
||||||
|
return dbInit[tableName]
|
||||||
|
.orderBy("id")
|
||||||
|
.reverse()
|
||||||
|
.offset(offset)
|
||||||
|
.limit(size)
|
||||||
|
.toArray();
|
||||||
|
},
|
||||||
|
async getAll(tableName: ChatTable) {
|
||||||
|
return dbInit[tableName].toArray()
|
||||||
|
},
|
||||||
|
async count(tableName: ChatTable) {
|
||||||
|
return dbInit[tableName].count()
|
||||||
|
},
|
||||||
|
async countSearch(tableName: ChatTable, whereObj?: any) {
|
||||||
|
return dbInit[tableName].where(whereObj).count()
|
||||||
|
},
|
||||||
|
async pageSearch(tableName: ChatTable, page?: number, size?: number, whereObj?: any) {
|
||||||
|
page = (!page || page < 1) ? 1 : page
|
||||||
|
size = size ? size : 10
|
||||||
|
const offset = (page - 1) * size
|
||||||
|
//console.log(whereObj)
|
||||||
|
return dbInit[tableName]
|
||||||
|
.where(whereObj)
|
||||||
|
.reverse()
|
||||||
|
.offset(offset)
|
||||||
|
.limit(size)
|
||||||
|
.toArray();
|
||||||
|
},
|
||||||
|
async filter(tableName: ChatTable, filterFunc : any) {
|
||||||
|
return dbInit[tableName].filter(filterFunc).toArray()
|
||||||
|
},
|
||||||
|
table(tableName: ChatTable) {
|
||||||
|
return dbInit[tableName]
|
||||||
|
},
|
||||||
|
async getOne(tableName: ChatTable, Id: number) {
|
||||||
|
return dbInit[tableName].get(Id)
|
||||||
|
},
|
||||||
|
async getRow(tableName: ChatTable, fieldName: string, val: any){
|
||||||
|
return dbInit[tableName].where(fieldName).equals(val).first()
|
||||||
|
},
|
||||||
|
async get(tableName: ChatTable, whereObj : any) {
|
||||||
|
//console.log(whereObj)
|
||||||
|
const data = await dbInit[tableName].where(whereObj).first()
|
||||||
|
//console.log(data)
|
||||||
|
return data? data : false
|
||||||
|
},
|
||||||
|
async rows(tableName: ChatTable, whereObj: any) {
|
||||||
|
return dbInit[tableName].where(whereObj).toArray()
|
||||||
|
},
|
||||||
|
async field(tableName: ChatTable, whereObj: any, field: string) {
|
||||||
|
const data = await this.get(tableName, whereObj)
|
||||||
|
return data ? data[field] : false
|
||||||
|
},
|
||||||
|
async getValue(tableName: ChatTable, fieldName: string, val: any, fName : string) {
|
||||||
|
const row = await this.getRow(tableName, fieldName, val);
|
||||||
|
return row[fName]
|
||||||
|
},
|
||||||
|
async getByField(tableName: ChatTable, fieldName: string, val: any) {
|
||||||
|
return dbInit[tableName].where(fieldName).equals(val).toArray()
|
||||||
|
},
|
||||||
|
async addOne(tableName: ChatTable, data: any) {
|
||||||
|
return dbInit[tableName].add(data)
|
||||||
|
},
|
||||||
|
async addAll(tableName: ChatTable, data: any) {
|
||||||
|
return dbInit[tableName].bulkAdd(data)
|
||||||
|
},
|
||||||
|
async update(tableName: ChatTable, Id?: number, updates?: any) {
|
||||||
|
return dbInit[tableName].update(Id, updates)
|
||||||
|
},
|
||||||
|
async modify(tableName: ChatTable, fieldName: string, val: any, updates: any) {
|
||||||
|
return dbInit[tableName].where(fieldName).equals(val).modify(updates)
|
||||||
|
},
|
||||||
|
async delete(tableName: ChatTable, Id?: number) {
|
||||||
|
return dbInit[tableName].delete(Id)
|
||||||
|
},
|
||||||
|
async deleteByField(tableName: ChatTable, fieldName: string, val: any) {
|
||||||
|
return dbInit[tableName].where(fieldName).equals(val).delete()
|
||||||
|
},
|
||||||
|
async clear(tableName: ChatTable) {
|
||||||
|
return dbInit[tableName].clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
464
frontend/src/stores/localchat.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import emojiList from "@/assets/emoji.json"
|
||||||
|
import { ref, toRaw, inject } from "vue";
|
||||||
|
import { db } from './db'
|
||||||
|
import { System,dirname } from "@/system";
|
||||||
|
import { getSystemConfig } from "@/system/config";
|
||||||
|
import { isBase64, base64ToBuffer } from "@/util/file";
|
||||||
|
import { notifyError, notifySuccess } from "@/util/msg";
|
||||||
|
export const useLocalChatStore = defineStore('localChatStore', () => {
|
||||||
|
const config = getSystemConfig();
|
||||||
|
const sys = inject<System>("system");
|
||||||
|
const userList:any = ref([])
|
||||||
|
const msgList:any = ref([])
|
||||||
|
const contentList:any = ref([])
|
||||||
|
const hostInfo:any = ref({})
|
||||||
|
const showChooseFile = ref(false)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const navList = ref([
|
||||||
|
{ index: 1, lable: "消息列表", icon: "ChatDotRound", type:"success" },
|
||||||
|
{ index: 2, lable: "用户列表", icon: "UserFilled", type: "info" },
|
||||||
|
])
|
||||||
|
const navId = ref(1)
|
||||||
|
const sendInfo = ref("")
|
||||||
|
const chatTargetId = ref(0)
|
||||||
|
const chatTargetIp = ref("")
|
||||||
|
const handleSelect = (key: number) => {
|
||||||
|
navId.value = key;
|
||||||
|
};
|
||||||
|
const setChatId = async(ip : string) => {
|
||||||
|
//console.log(ip)
|
||||||
|
//chatTargetId.value = id
|
||||||
|
chatTargetIp.value = ip
|
||||||
|
const data = await db.get("chatuser", {ip : ip})
|
||||||
|
if(!data)return;
|
||||||
|
chatTargetId.value = data.id
|
||||||
|
clearContentList(data.id)
|
||||||
|
currentPage.value = 1
|
||||||
|
await getMsgList()
|
||||||
|
}
|
||||||
|
const initContentList = async () => {
|
||||||
|
const list:any = {}
|
||||||
|
const msgAll = await db.getAll('chatmsg')
|
||||||
|
msgAll.forEach((d : any) => {
|
||||||
|
if(!d.isMe){
|
||||||
|
if (!list[d.targetIp]){
|
||||||
|
list[d.targetIp] = []
|
||||||
|
}
|
||||||
|
list[d.targetIp].push(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
const res = []
|
||||||
|
for(const p in list){
|
||||||
|
const chatArr = list[p]
|
||||||
|
let readNum = 0
|
||||||
|
chatArr.forEach((d:any) => {
|
||||||
|
if(!d.isRead){
|
||||||
|
readNum++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const last = chatArr.pop()
|
||||||
|
last.readNum = readNum
|
||||||
|
res.push(last)
|
||||||
|
}
|
||||||
|
contentList.value = res.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
|
const clearContentList = (targetId:number) => {
|
||||||
|
contentList.value.forEach((d : any) => {
|
||||||
|
if(d.targetId === targetId) {
|
||||||
|
d.readNum = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const clearMsg = async () => {
|
||||||
|
if (chatTargetIp.value === '') return
|
||||||
|
await db.deleteByField('chatmsg','targetIp', chatTargetIp.value)
|
||||||
|
msgList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateContentList = async (msg:any) => {
|
||||||
|
if(msg.isMe)return;
|
||||||
|
const has = contentList.value.find((d:any) => d.targetIp === msg.targetIp)
|
||||||
|
if(has){
|
||||||
|
contentList.value.forEach((d: any, index: number) => {
|
||||||
|
if (d.targetIp === msg.targetIp) {
|
||||||
|
if (!msg.isRead) {
|
||||||
|
msg.readNum = d.readNum + 1;
|
||||||
|
} else {
|
||||||
|
msg.readNum = 0;
|
||||||
|
}
|
||||||
|
// 直接替换数组中的元素以触发更新
|
||||||
|
contentList.value.splice(index, 1, msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
//console.log(contentList.value)
|
||||||
|
}else{
|
||||||
|
if(msg.isRead){
|
||||||
|
msg.readNum = 0
|
||||||
|
}else{
|
||||||
|
msg.readNum = 1
|
||||||
|
}
|
||||||
|
contentList.value.unshift(msg)
|
||||||
|
//console.log(contentList.value)
|
||||||
|
}
|
||||||
|
contentList.value = contentList.value.sort((a: any, b: any) => b.createdAt - a.createdAt)
|
||||||
|
}
|
||||||
|
const init = async() => {
|
||||||
|
await getUserList()
|
||||||
|
await initUserList()
|
||||||
|
await initContentList()
|
||||||
|
}
|
||||||
|
const initUserList = async() => {
|
||||||
|
if(userList.value.length > 0) {
|
||||||
|
const updates : any = []
|
||||||
|
userList.value.forEach((d: any) => {
|
||||||
|
if (d.isOnline) {
|
||||||
|
updates.push({
|
||||||
|
key: d.id,
|
||||||
|
changes: {
|
||||||
|
isOnline: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await db.table('chatuser').bulkUpdate(updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const refreshUserList = async () => {
|
||||||
|
await db.clear('chatuser')
|
||||||
|
userList.value = []
|
||||||
|
}
|
||||||
|
const getMsgList = async () => {
|
||||||
|
if (chatTargetId.value < 1)return;
|
||||||
|
//msgList.value = await db.getByField('chatmsg', 'targetId', chatTargetId.value)
|
||||||
|
//msgList.value = await db.pageSearch('chatmsg', currentPage.value, pageSize.value, { targetId: chatTargetId.value })
|
||||||
|
const offset = (currentPage.value - 1) * pageSize.value
|
||||||
|
const list = await db.table('chatmsg')
|
||||||
|
.where({ targetIp: chatTargetIp.value })
|
||||||
|
.desc()
|
||||||
|
.offset(offset)
|
||||||
|
.limit(pageSize.value)
|
||||||
|
.toArray();
|
||||||
|
list.sort((a:any,b:any) => a.id > b.id)
|
||||||
|
msgList.value = list;
|
||||||
|
}
|
||||||
|
const moreMsgList = async() => {
|
||||||
|
if (chatTargetId.value < 1) return;
|
||||||
|
//const list = await db.pageSearch('chatmsg', currentPage.value + 1, pageSize.value, { targetId: chatTargetId.value })
|
||||||
|
const offset = currentPage.value * pageSize.value
|
||||||
|
const list = await db.table('chatmsg')
|
||||||
|
.where({ targetIp: chatTargetIp.value })
|
||||||
|
.desc()
|
||||||
|
.offset(offset)
|
||||||
|
.limit(pageSize.value)
|
||||||
|
.toArray();
|
||||||
|
if(list && list.length > 0) {
|
||||||
|
list.sort((a: any, b: any) => a.id > b.id)
|
||||||
|
currentPage.value = currentPage.value + 1
|
||||||
|
msgList.value = [...list, ...msgList.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getUserList = async () => {
|
||||||
|
const list = await db.getAll('chatuser')
|
||||||
|
list.sort((a: any, b: any) => a.updatedAt > b.updatedAt)
|
||||||
|
userList.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUserList = async (data:any) => {
|
||||||
|
//console.log(data)
|
||||||
|
if(data.length < 1){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostInfo.value = data[0]
|
||||||
|
data.shift()
|
||||||
|
const ips:any = []
|
||||||
|
userList.value.forEach((d : any) => {
|
||||||
|
ips.push(d.ip)
|
||||||
|
});
|
||||||
|
const has:any = []
|
||||||
|
const nothas:any = []
|
||||||
|
data.forEach((d:any) => {
|
||||||
|
if(ips.includes(d.ip)){
|
||||||
|
has.push(d.ip)
|
||||||
|
}else{
|
||||||
|
nothas.push(d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if(has.length > 0) {
|
||||||
|
const updates:any = []
|
||||||
|
userList.value.forEach((d: any) => {
|
||||||
|
if(has.includes(d.ip)){
|
||||||
|
updates.push({
|
||||||
|
key : d.id,
|
||||||
|
changes : {
|
||||||
|
isOnline : true,
|
||||||
|
updatedAt:Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await db.table('chatuser').bulkUpdate(updates)
|
||||||
|
}
|
||||||
|
if(nothas.length > 0) {
|
||||||
|
nothas.forEach((d:any) => {
|
||||||
|
d.isOnline = true
|
||||||
|
d.username = data.hostname
|
||||||
|
d.createdAt = Date.now()
|
||||||
|
d.updatedAt = Date.now()
|
||||||
|
})
|
||||||
|
await db.table('chatuser').bulkAdd(nothas)
|
||||||
|
}
|
||||||
|
await getUserList()
|
||||||
|
}
|
||||||
|
const addFile = async (data:any) => {
|
||||||
|
const targetUser:any = await getTargetUser(data)
|
||||||
|
const files:any = []
|
||||||
|
data.fileList.forEach((d:any) => {
|
||||||
|
d.save_path = d.save_path.replace(/\\/g, "/");
|
||||||
|
files.push({
|
||||||
|
name : d.name,
|
||||||
|
path : d.save_path,
|
||||||
|
ext : d.save_path.split('.').pop(),
|
||||||
|
content: d.content
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const saveMsg: any = {
|
||||||
|
type: 'file',
|
||||||
|
targetId: targetUser.id,
|
||||||
|
targetIp: targetUser.ip,
|
||||||
|
content: files,
|
||||||
|
reciperInfo: data.senderInfo,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isMe: false,
|
||||||
|
isRead: false,
|
||||||
|
status: 'reciped'
|
||||||
|
}
|
||||||
|
if (targetUser.id === chatTargetId.value) {
|
||||||
|
saveMsg.readAt = Date.now()
|
||||||
|
saveMsg.isRead = true
|
||||||
|
msgList.value.push(saveMsg)
|
||||||
|
}
|
||||||
|
console.log(saveMsg)
|
||||||
|
await db.addOne('chatmsg', saveMsg)
|
||||||
|
//await getMsgList()
|
||||||
|
|
||||||
|
await updateContentList(saveMsg)
|
||||||
|
if (config.storeType === 'browser') {
|
||||||
|
await storeFile(files)
|
||||||
|
}
|
||||||
|
handleSelect(1)
|
||||||
|
}
|
||||||
|
const storeFile = async(fileList : any) => {
|
||||||
|
if (fileList.length < 1) return;
|
||||||
|
console.log(fileList)
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
let content = fileList[i].content
|
||||||
|
if(typeof content === 'string') {
|
||||||
|
if(isBase64(content)){
|
||||||
|
content = base64ToBuffer(content);
|
||||||
|
}
|
||||||
|
const path = dirname(fileList[i].path)
|
||||||
|
//console.log(path)
|
||||||
|
await sys?.fs.mkdir(path);
|
||||||
|
await sys?.fs.writeFile(fileList[i].path, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const getTargetUser = async (data:any) => {
|
||||||
|
let targetUser:any = userList.value.find((d: any) => d.ip === data.senderInfo.ip)
|
||||||
|
if (!targetUser){
|
||||||
|
targetUser = {
|
||||||
|
isOnline : true,
|
||||||
|
ip:data.senderInfo.ip,
|
||||||
|
hostname: data.senderInfo.hostname,
|
||||||
|
username : data.senderInfo.hostname,
|
||||||
|
createdAt : Date.now(),
|
||||||
|
updatedAt : Date.now()
|
||||||
|
}
|
||||||
|
targetUser.id = await db.addOne("chatuser",targetUser)
|
||||||
|
userList.value.unshift(targetUser)
|
||||||
|
}
|
||||||
|
return targetUser
|
||||||
|
}
|
||||||
|
const addText = async (data:any) => {
|
||||||
|
const targetUser:any = await getTargetUser(data)
|
||||||
|
|
||||||
|
const saveMsg:any = {
|
||||||
|
type: 'text',
|
||||||
|
targetId: targetUser.id,
|
||||||
|
targetIp: targetUser.ip,
|
||||||
|
content: data.content,
|
||||||
|
reciperInfo: data.senderInfo,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isMe: false,
|
||||||
|
isRead: false,
|
||||||
|
status: 'reciped'
|
||||||
|
}
|
||||||
|
if (targetUser.id === chatTargetId.value){
|
||||||
|
saveMsg.readAt = Date.now()
|
||||||
|
saveMsg.isRead = true
|
||||||
|
msgList.value.push(saveMsg)
|
||||||
|
}
|
||||||
|
//console.log(saveMsg)
|
||||||
|
await db.addOne('chatmsg', saveMsg)
|
||||||
|
//await getMsgList()
|
||||||
|
|
||||||
|
await updateContentList(saveMsg)
|
||||||
|
handleSelect(1)
|
||||||
|
}
|
||||||
|
const sendMsg = async () => {
|
||||||
|
if(chatTargetId.value < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const saveMsg:any = {
|
||||||
|
type : 'text',
|
||||||
|
targetId : chatTargetId.value,
|
||||||
|
targetIp: chatTargetIp.value,
|
||||||
|
content: sendInfo.value.trim(),
|
||||||
|
senderInfo: toRaw(hostInfo.value),
|
||||||
|
createdAt:Date.now(),
|
||||||
|
isMe:true,
|
||||||
|
isRead:false,
|
||||||
|
status:'sending'
|
||||||
|
}
|
||||||
|
//console.log(saveMsg)
|
||||||
|
const msgId = await db.addOne('chatmsg', saveMsg)
|
||||||
|
//await getMsgList()
|
||||||
|
msgList.value.push(saveMsg)
|
||||||
|
const targetUser = userList.value.find((d: any) => d.id === chatTargetId.value)
|
||||||
|
//console.log(targetUser)
|
||||||
|
if(targetUser.isOnline) {
|
||||||
|
const postUrl = `http://${targetUser.ip}:56780/message`
|
||||||
|
const completion = await fetch(postUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(saveMsg),
|
||||||
|
})
|
||||||
|
if (!completion.ok) {
|
||||||
|
console.log(completion)
|
||||||
|
}else{
|
||||||
|
saveMsg.isRead = true
|
||||||
|
saveMsg.status = 'sended'
|
||||||
|
saveMsg.readAt = Date.now()
|
||||||
|
await db.update('chatmsg', msgId, saveMsg)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendInfo.value = ""
|
||||||
|
await updateContentList(saveMsg)
|
||||||
|
|
||||||
|
}
|
||||||
|
//上传文件资源
|
||||||
|
async function uploadFile(paths:any) {
|
||||||
|
if (chatTargetId.value < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetUser = userList.value.find((d: any) => d.id === chatTargetId.value)
|
||||||
|
if (!targetUser.isOnline) {
|
||||||
|
notifyError("The user is not online!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hostInfo.value || !hostInfo.value.ip) {
|
||||||
|
notifyError("Please wait for a moment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//console.log(paths)
|
||||||
|
const formData = new FormData();
|
||||||
|
const errstr:any = []
|
||||||
|
const files:any = []
|
||||||
|
for (let i = 0; i < paths.length; i++) {
|
||||||
|
const content = await sys?.fs.readFile(paths[i]);
|
||||||
|
let blobContent;
|
||||||
|
if(!content || content == ''){
|
||||||
|
errstr.push(paths[i] + " is empty")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (content instanceof ArrayBuffer) {
|
||||||
|
blobContent = new Blob([content]);
|
||||||
|
}
|
||||||
|
else if (typeof content === 'string') {
|
||||||
|
if (isBase64(content)) {
|
||||||
|
const base64 = base64ToBuffer(content);
|
||||||
|
blobContent = new Blob([base64]);
|
||||||
|
} else {
|
||||||
|
blobContent = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
errstr.push(paths[i] + " type is error")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const fileName = paths[i].split("/").pop()
|
||||||
|
files.push({
|
||||||
|
name: fileName,
|
||||||
|
path: paths[i],
|
||||||
|
ext : fileName.split(".").pop(),
|
||||||
|
})
|
||||||
|
//files.push(blobContent);
|
||||||
|
formData.append(`files`, blobContent, fileName);
|
||||||
|
}
|
||||||
|
if(errstr.length > 0) {
|
||||||
|
errstr.forEach((d:any) => {
|
||||||
|
notifyError(d);
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//formData.append("files", files);
|
||||||
|
formData.append("ip", hostInfo.value.ip);
|
||||||
|
formData.append("hostname", hostInfo.value.hostname);
|
||||||
|
//console.log(formData)
|
||||||
|
const postUrl = `http://${targetUser.ip}:56780/upload`
|
||||||
|
const res = await fetch(postUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(res);
|
||||||
|
notifyError("Upload error!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saveMsg: any = {
|
||||||
|
type: 'file',
|
||||||
|
targetId: targetUser.id,
|
||||||
|
targetIp: targetUser.ip,
|
||||||
|
content: files,
|
||||||
|
reciperInfo: toRaw(targetUser),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isMe: false,
|
||||||
|
isRead: true,
|
||||||
|
status: 'reciped'
|
||||||
|
}
|
||||||
|
console.log(saveMsg)
|
||||||
|
await db.addOne('chatmsg', saveMsg)
|
||||||
|
msgList.value.push(saveMsg)
|
||||||
|
|
||||||
|
notifySuccess("upload success!");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userList,
|
||||||
|
navList,
|
||||||
|
sendInfo,
|
||||||
|
navId,
|
||||||
|
chatTargetId,
|
||||||
|
chatTargetIp,
|
||||||
|
msgList,
|
||||||
|
hostInfo,
|
||||||
|
contentList,
|
||||||
|
emojiList,
|
||||||
|
showChooseFile,
|
||||||
|
pageSize,
|
||||||
|
init,
|
||||||
|
setUserList,
|
||||||
|
getUserList,
|
||||||
|
handleSelect,
|
||||||
|
setChatId,
|
||||||
|
sendMsg,
|
||||||
|
addText,
|
||||||
|
addFile,
|
||||||
|
uploadFile,
|
||||||
|
moreMsgList,
|
||||||
|
refreshUserList,
|
||||||
|
clearMsg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -181,20 +181,18 @@ export const appList = [
|
||||||
ext: ['bb'],
|
ext: ['bb'],
|
||||||
eventType: "exportBaiban"
|
eventType: "exportBaiban"
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// name: 'localchat',
|
name: 'localchat',
|
||||||
// appIcon: "chat",
|
appIcon: "chat",
|
||||||
// content: Chat,
|
content: "Chat",
|
||||||
// view: Chat,
|
frame: true,
|
||||||
// frame: true,
|
width: 800,
|
||||||
// width: 800,
|
height: 600,
|
||||||
// height: 600,
|
center: true,
|
||||||
// center: true,
|
resizable: true,
|
||||||
// resizable: true,
|
isDeskTop: true,
|
||||||
// isDeskTop: true,
|
isMagnet: false,
|
||||||
// isMagnet: false,
|
},
|
||||||
// ext: []
|
|
||||||
// },
|
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'piceditor',
|
name: 'piceditor',
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const getSystemConfig = (ifset = false) => {
|
||||||
}
|
}
|
||||||
// 初始化API相关URL,若本地存储中已存在则不进行覆盖
|
// 初始化API相关URL,若本地存储中已存在则不进行覆盖
|
||||||
if (!config.apiUrl) {
|
if (!config.apiUrl) {
|
||||||
config.apiUrl = 'http://localhost:56710';
|
config.apiUrl = 'http://localhost:56780';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化用户信息,若本地存储中已存在则不进行覆盖
|
// 初始化用户信息,若本地存储中已存在则不进行覆盖
|
||||||
|
|
|
||||||
111
frontend/src/util/update.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//import { EventsOff, EventsOn } from '~/runtime';
|
||||||
|
import manifest from '../../package.json';
|
||||||
|
export async function checkUpdate() {
|
||||||
|
if(!(window as any).go) return;
|
||||||
|
const updateGiteeUrl = `https://gitee.com/api/v5/repos/ruitao_admin/godoos/releases/`
|
||||||
|
const releaseRes = await fetch(updateGiteeUrl)
|
||||||
|
if(!releaseRes.ok) return;
|
||||||
|
const releaseData = await releaseRes.json()
|
||||||
|
const versionTag = releaseData.tag_name;
|
||||||
|
if(!versionTag) return;
|
||||||
|
if (versionTag.replace('v', '') <= manifest.version) return;
|
||||||
|
const verifyUrl = `${updateGiteeUrl}tags/${versionTag}`;
|
||||||
|
const verRes = await fetch(verifyUrl);
|
||||||
|
if(!verRes.ok) return;
|
||||||
|
const verData = await verRes.json()
|
||||||
|
if(!verData.assets || verData.assets.length <= 0) return;
|
||||||
|
const updateUrl = `${updateGiteeUrl}download/${versionTag}/${asset.name}`;
|
||||||
|
|
||||||
|
fetch(`${updateGiteeUrl}latest`).then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
r.json().then((data) => {
|
||||||
|
if (data.tag_name) {
|
||||||
|
// const versionTag = data.tag_name;
|
||||||
|
// console.log(versionTag)
|
||||||
|
// if (versionTag.replace('v', '') > manifest.version) {
|
||||||
|
// const verifyUrl = `${updateGiteeUrl}tags/${versionTag}`;
|
||||||
|
// }
|
||||||
|
/*
|
||||||
|
if (versionTag.replace('v', '') > manifest.version) {
|
||||||
|
const verifyUrl = `${updateGiteeUrl}tags/${versionTag}`;
|
||||||
|
|
||||||
|
fetch(verifyUrl).then((r) => {
|
||||||
|
if (r.ok) {
|
||||||
|
r.json().then((data) => {
|
||||||
|
if (data.assets && data.assets.length > 0) {
|
||||||
|
const asset = data.assets.find((a: any) => a.name.toLowerCase().includes(commonStore.platform.toLowerCase().replace('darwin', 'macos')));
|
||||||
|
if (asset) {
|
||||||
|
const updateUrl = `${updateGiteeUrl}download/${versionTag}/${asset.name}`;
|
||||||
|
toastWithButton(t('New Version Available') + ': ' + versionTag, t('Update'), () => {
|
||||||
|
DeleteFile('cache.json');
|
||||||
|
const progressId = 'update_app';
|
||||||
|
const progressEvent = 'updateApp';
|
||||||
|
const updateProgress = (ds: DownloadStatus | null) => {
|
||||||
|
const content =
|
||||||
|
t('Downloading update, please wait. If it is not completed, please manually download the program from GitHub and replace the original program.')
|
||||||
|
+ (ds ? ` (${ds.progress.toFixed(2)}% ${bytesToReadable(ds.transferred)}/${bytesToReadable(ds.size)})` : '');
|
||||||
|
const options: ToastOptions = {
|
||||||
|
type: 'info',
|
||||||
|
position: 'bottom-left',
|
||||||
|
autoClose: false,
|
||||||
|
toastId: progressId,
|
||||||
|
hideProgressBar: false,
|
||||||
|
progress: ds ? ds.progress / 100 : 0
|
||||||
|
};
|
||||||
|
if (toast.isActive(progressId))
|
||||||
|
toast.update(progressId, {
|
||||||
|
render: content,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
else
|
||||||
|
toast(content, options);
|
||||||
|
};
|
||||||
|
updateProgress(null);
|
||||||
|
EventsOn(progressEvent, updateProgress);
|
||||||
|
UpdateApp(updateUrl).then(() => {
|
||||||
|
toast(t('Update completed, please restart the program.'), {
|
||||||
|
type: 'success',
|
||||||
|
position: 'bottom-left',
|
||||||
|
autoClose: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).catch((e) => {
|
||||||
|
toast(t('Update Error') + ' - ' + (e.message || e), {
|
||||||
|
type: 'error',
|
||||||
|
position: 'bottom-left',
|
||||||
|
autoClose: false
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
toast.dismiss(progressId);
|
||||||
|
EventsOff(progressEvent);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
autoClose: false,
|
||||||
|
position: 'bottom-left'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Verify response was not ok.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (notifyEvenLatest) {
|
||||||
|
toast(t('This is the latest version'), { type: 'success', position: 'bottom-left', autoClose: 2000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Network response was not ok.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch((e) => {
|
||||||
|
//toast(t('Updates Check Error') + ' - ' + (e.message || e), { type: 'error', position: 'bottom-left' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -34,10 +34,11 @@
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"outDir": "./",
|
"outDir": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"~/*": ["./wailsjs/*"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts","src/**/**/*.ts", "src/**/*", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["src/**/*.ts","wailsjs/**/*.ts","wailsjs/**/*.js","src/**/**/*.ts", "src/**/*", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ export default defineConfig(async () => ({
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, 'src')
|
"@": path.resolve(__dirname, 'src'),
|
||||||
|
"~": path.resolve(__dirname, 'wailsjs')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build:{
|
build:{
|
||||||
|
|
|
||||||
3
go.mod
|
|
@ -5,12 +5,15 @@ go 1.21
|
||||||
toolchain go1.22.5
|
toolchain go1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||||
github.com/fsnotify/fsnotify v1.4.9
|
github.com/fsnotify/fsnotify v1.4.9
|
||||||
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/shirou/gopsutil v2.21.11+incompatible
|
github.com/shirou/gopsutil v2.21.11+incompatible
|
||||||
github.com/wailsapp/wails/v2 v2.9.1
|
github.com/wailsapp/wails/v2 v2.9.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
aead.dev/minisign v0.2.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
|
|
||||||
17
go.sum
|
|
@ -1,5 +1,9 @@
|
||||||
|
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
||||||
|
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
|
||||||
|
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -39,6 +43,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||||
|
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
|
@ -75,18 +81,27 @@ github.com/wailsapp/wails/v2 v2.9.1 h1:irsXnoQrCpeKzKTYZ2SUVlRRyeMR6I0vCO9Q1cvlE
|
||||||
github.com/wailsapp/wails/v2 v2.9.1/go.mod h1:7maJV2h+Egl11Ak8QZN/jlGLj2wg05bsQS+ywJPT0gI=
|
github.com/wailsapp/wails/v2 v2.9.1/go.mod h1:7maJV2h+Egl11Ak8QZN/jlGLj2wg05bsQS+ywJPT0gI=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -95,7 +110,9 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
|
|
||||||
142
localchat/client.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// func StartServiceDiscovery() {
|
||||||
|
// conn, err := net.ListenPacket("udp", broadcastAddr)
|
||||||
|
// if err != nil {
|
||||||
|
// fmt.Println(err)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// defer conn.Close()
|
||||||
|
|
||||||
|
// buffer := make([]byte, 1024)
|
||||||
|
// for {
|
||||||
|
// n, addr, err := conn.ReadFrom(buffer)
|
||||||
|
// if err != nil {
|
||||||
|
// fmt.Println(err)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// fmt.Printf("Received message: %s from %s\n", buffer[:n], addr)
|
||||||
|
// var udpMsg UdpMessage
|
||||||
|
// err = json.Unmarshal(buffer[:n], &udpMsg)
|
||||||
|
// if err != nil {
|
||||||
|
// fmt.Printf("Error unmarshalling JSON: %v\n", err)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// log.Printf("Get message: %+v", udpMsg)
|
||||||
|
// OnlineUsers[udpMsg.IP] = udpMsg
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
func DiscoverServers() {
|
||||||
|
broadcastTicker := time.NewTicker(broadcartTime)
|
||||||
|
done := make(chan struct{}) // New channel to signal when to stop
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-broadcastTicker.C:
|
||||||
|
conn, err := net.Dial("udp", broadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
myIP, myHostname, err := getMyIPAndHostname()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get my IP and hostname: %v", err)
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := UdpMessage{
|
||||||
|
Type: "online",
|
||||||
|
Hostname: myHostname,
|
||||||
|
IP: myIP,
|
||||||
|
Message: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to marshal message to JSON: %v", err)
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Sending message: %+v", msg)
|
||||||
|
_, err = conn.Write(jsonData)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
conn.Close() // Close the connection after use
|
||||||
|
case <-done: // Signal to stop
|
||||||
|
broadcastTicker.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// You might have some condition here to decide when to stop the ticker
|
||||||
|
// For example, a signal handling mechanism or a specific duration.
|
||||||
|
// After that condition is met, you would call:
|
||||||
|
// close(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取自己的IP地址和主机名
|
||||||
|
func getMyIPAndHostname() (string, string, error) {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to get network interfaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferredIP net.IP
|
||||||
|
for _, iface := range addrs {
|
||||||
|
if iface.Flags&net.FlagUp == 0 {
|
||||||
|
// Skip interfaces that are not up
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ifAddrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue // Ignore this interface if we can't get its addresses
|
||||||
|
}
|
||||||
|
for _, addr := range ifAddrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := addr.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
case *net.IPAddr:
|
||||||
|
ip = v.IP
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() {
|
||||||
|
continue // Skip loopback addresses
|
||||||
|
}
|
||||||
|
if ip.To4() != nil && (ip.IsPrivate() || ip.IsGlobalUnicast()) {
|
||||||
|
// Prefer global unicast or private addresses over link-local
|
||||||
|
preferredIP = ip
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preferredIP != nil {
|
||||||
|
// Found a preferred IP, break out of the loop
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferredIP == nil {
|
||||||
|
return "", "", fmt.Errorf("no preferred non-loopback IPv4 address found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredIP.String(), hostname, nil
|
||||||
|
}
|
||||||
44
localchat/file.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetChatPath() (string, error) {
|
||||||
|
baseDir, err := getAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
modelDir := filepath.Join(baseDir, "C", "Users", "Reciv", time.Now().Format("2006-01-02"))
|
||||||
|
if !pathExists(modelDir) {
|
||||||
|
os.MkdirAll(modelDir, 0755)
|
||||||
|
}
|
||||||
|
return modelDir, nil
|
||||||
|
}
|
||||||
|
func getAppDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, ".godoos", "os"), nil
|
||||||
|
}
|
||||||
|
func pathExists(dir string) bool {
|
||||||
|
_, err := os.Stat(dir)
|
||||||
|
if err == nil {
|
||||||
|
//log.Println("文件夹存在")
|
||||||
|
return true
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
//log.Println("文件夹不存在")
|
||||||
|
return false
|
||||||
|
} else if os.IsExist(err) {
|
||||||
|
//log.Println("文件夹存在")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
log.Println("发生错误:", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
84
localchat/server.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartServiceDiscovery() {
|
||||||
|
conn, err := net.ListenPacket("udp", broadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, addr, err := conn.ReadFrom(buffer)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("Received message: %s from %s\n", buffer[:n], addr)
|
||||||
|
var udpMsg UdpMessage
|
||||||
|
err = json.Unmarshal(buffer[:n], &udpMsg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error unmarshalling JSON: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Get message: %+v", udpMsg)
|
||||||
|
OnlineUsers[udpMsg.IP] = udpMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
func StartServiceDiscovery(port int, clientPort int) {
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
serviceAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve UDP address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", serviceAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen on UDP: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get hostname: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
// 监听UDP广播请求
|
||||||
|
_, _, err := conn.ReadFromUDP(buffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading from UDP: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建广播消息,包含IP和主机名
|
||||||
|
ip, _, _ := net.SplitHostPort(serviceAddr.String())
|
||||||
|
response := fmt.Sprintf("Chat Server is running at IP: %s, Hostname: %s", ip, hostname)
|
||||||
|
broadcastMessage(conn, response, clientPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastMessage(conn *net.UDPConn, message string, clientPort int) {
|
||||||
|
// 广播给所有客户端
|
||||||
|
broadcastAddr := &net.UDPAddr{IP: net.ParseIP("255.255.255.255"), Port: clientPort}
|
||||||
|
_, err := conn.WriteToUDP([]byte(message), broadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to broadcast message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
112
localchat/sse.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go StartServiceDiscovery()
|
||||||
|
go DiscoverServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
ticker := time.NewTicker(broadcartTime) // 每3秒检查一次在线用户
|
||||||
|
defer ticker.Stop()
|
||||||
|
// 处理新消息
|
||||||
|
ctx := r.Context()
|
||||||
|
// 使用Context来监听请求的取消
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("Recovered in SSE goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done(): // 当请求被取消时,退出循环
|
||||||
|
return
|
||||||
|
case msg := <-messageChan:
|
||||||
|
// 构造JSON数据
|
||||||
|
jsonData, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to marshal message to JSON: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过SSE发送JSON数据
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", string(jsonData))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
myIP, myHostname, err := getMyIPAndHostname()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get my IP and hostname: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C: // 每隔一段时间检查并广播在线用户
|
||||||
|
var userList []UdpMessage
|
||||||
|
// 首先将自己的IP和主机名放入列表
|
||||||
|
myMsg := UdpMessage{
|
||||||
|
IP: myIP,
|
||||||
|
Hostname: myHostname,
|
||||||
|
Type: "online",
|
||||||
|
Message: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
userList = append(userList, myMsg)
|
||||||
|
log.Printf("Online users: %v", OnlineUsers)
|
||||||
|
for ip, info := range OnlineUsers {
|
||||||
|
if ip != myIP { // 确保不重复添加自己
|
||||||
|
userList = append(userList, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := UserList{Type: "user_list", Content: userList}
|
||||||
|
// 将用户列表转换为JSON字符串
|
||||||
|
jsonData, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to marshal online users to JSON: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 通过SSE发送JSON数据
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", string(jsonData))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func HandleMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
if err := decoder.Decode(&msg); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
// 将消息放入messageChan
|
||||||
|
messageChan <- msg
|
||||||
|
log.Printf("Received text message from %s: %s", msg.SenderInfo.IP, msg.Content)
|
||||||
|
// 这里可以添加存储文本消息到数据库或其他处理逻辑
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "Text message send successfully")
|
||||||
|
}
|
||||||
61
localchat/type.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// type SenderInfo struct {
|
||||||
|
// SenderIP string `json:"sender_ip"`
|
||||||
|
// Username string `json:"username"`
|
||||||
|
// }
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"` // 消息类型,如"text"、"image"等
|
||||||
|
Content string `json:"content"` // 消息内容
|
||||||
|
SenderInfo UserInfo `json:"senderInfo"` // 发送者的IP地址
|
||||||
|
FileInfo FilePartInfo `json:"fileInfo"`
|
||||||
|
FileList []UploadInfo `json:"fileList"`
|
||||||
|
}
|
||||||
|
type UserInfo struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
type UdpMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
type UserList struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content []UdpMessage `json:"content"`
|
||||||
|
}
|
||||||
|
type UploadInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SavePath string `json:"save_path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件分片信息
|
||||||
|
type FilePartInfo struct {
|
||||||
|
FileName string `json:"filename"`
|
||||||
|
PartNumber int `json:"part_number"`
|
||||||
|
TotalParts int `json:"total_parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分片上传状态跟踪
|
||||||
|
type UploadStatus struct {
|
||||||
|
sync.Mutex
|
||||||
|
Status map[string]int // key: fileName, value: 已上传分片数
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
messageChan = make(chan Message, 100) // 缓存大小根据实际情况设定
|
||||||
|
)
|
||||||
|
var uploadStatus = UploadStatus{Status: make(map[string]int)}
|
||||||
|
var broadcartTime = 3 * time.Second
|
||||||
|
|
||||||
|
var broadcastAddr = "224.0.0.1:1679" // 多播地址
|
||||||
|
// var broadcastAddr = "255.255.255.255:1769" // 广播地址
|
||||||
|
var OnlineUsers = make(map[string]UdpMessage) // 全局map,key为IP,value为主机名
|
||||||
204
localchat/upload.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 合并文件分片
|
||||||
|
func mergeFiles(fileName string, totalParts int, chatDir string) error {
|
||||||
|
var parts []io.Reader
|
||||||
|
for i := 1; i <= totalParts; i++ {
|
||||||
|
filePath := fmt.Sprintf("%v%v_%v.part", chatDir, fileName, i)
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open part %d: %w", i, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
parts = append(parts, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedFilePath := fmt.Sprintf("%vmerged_%v", chatDir, fileName)
|
||||||
|
mergedFile, err := os.Create(mergedFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create merged file: %w", err)
|
||||||
|
}
|
||||||
|
defer mergedFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(mergedFile, io.MultiReader(parts...))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to merge files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并后清理分片文件
|
||||||
|
for i := 1; i <= totalParts; i++ {
|
||||||
|
os.Remove(fmt.Sprintf("%v%v_%v.part", chatDir, fileName, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadBigFileHandler(w http.ResponseWriter, r *http.Request, msg Message) {
|
||||||
|
|
||||||
|
chatDir, err := GetChatPath()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get chat path", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建或打开临时文件以写入分片
|
||||||
|
tempFilePath := fmt.Sprintf("%v%v_%v.part", chatDir, msg.FileInfo.FileName, msg.FileInfo.PartNumber)
|
||||||
|
out, err := os.Create(tempFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create temp file: %v", err)
|
||||||
|
http.Error(w, "Failed to create temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// 将请求体的内容写入临时文件
|
||||||
|
_, err = io.Copy(out, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to write file part: %v", err)
|
||||||
|
http.Error(w, "Failed to write file part", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新上传状态
|
||||||
|
uploadStatus.Lock()
|
||||||
|
uploadStatus.Status[msg.FileInfo.FileName]++
|
||||||
|
if uploadStatus.Status[msg.FileInfo.FileName] == msg.FileInfo.TotalParts {
|
||||||
|
// 所有分片上传完成,触发合并
|
||||||
|
go func() {
|
||||||
|
err := mergeFiles(msg.FileInfo.FileName, msg.FileInfo.TotalParts, chatDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to merge files for %v: %v", msg.FileInfo.FileName, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Merged file %v successfully", msg.FileInfo.FileName)
|
||||||
|
}
|
||||||
|
// 清理状态记录
|
||||||
|
delete(uploadStatus.Status, msg.FileInfo.FileName)
|
||||||
|
msg.Content = "uploaded"
|
||||||
|
messageChan <- msg
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
uploadStatus.Unlock()
|
||||||
|
|
||||||
|
// 返回成功响应
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
fmt.Fprintln(w, "File part uploaded successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveContentToFile 保存内容到文件并返回UploadInfo结构体
|
||||||
|
func SaveContentToFile(content, fileName string) (UploadInfo, error) {
|
||||||
|
uploadBaseDir, err := GetChatPath()
|
||||||
|
if err != nil {
|
||||||
|
return UploadInfo{}, err
|
||||||
|
}
|
||||||
|
appDir, err := getAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return UploadInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除文件名中的空格
|
||||||
|
fileNameWithoutSpaces := strings.ReplaceAll(fileName, " ", "_")
|
||||||
|
fileNameWithoutSpaces = strings.ReplaceAll(fileNameWithoutSpaces, "/", "")
|
||||||
|
fileNameWithoutSpaces = strings.ReplaceAll(fileNameWithoutSpaces, `\`, "")
|
||||||
|
// 提取文件名和扩展名
|
||||||
|
// 查找最后一个点的位置
|
||||||
|
lastDotIndex := strings.LastIndexByte(fileNameWithoutSpaces, '.')
|
||||||
|
|
||||||
|
// 如果找到点,则提取扩展名,否则视为没有扩展名
|
||||||
|
ext := ""
|
||||||
|
if lastDotIndex != -1 {
|
||||||
|
ext = fileNameWithoutSpaces[lastDotIndex:]
|
||||||
|
fileNameWithoutSpaces = fileNameWithoutSpaces[:lastDotIndex]
|
||||||
|
} else {
|
||||||
|
ext = ""
|
||||||
|
}
|
||||||
|
randFileName := fmt.Sprintf("%s_%s%s", fileNameWithoutSpaces, strconv.FormatInt(time.Now().UnixNano(), 10), ext)
|
||||||
|
savePath := filepath.Join(uploadBaseDir, randFileName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil {
|
||||||
|
return UploadInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(savePath, []byte(content), 0644); err != nil {
|
||||||
|
return UploadInfo{}, err
|
||||||
|
}
|
||||||
|
content = string(content)
|
||||||
|
// 检查文件内容是否以"link::"开头
|
||||||
|
if !strings.HasPrefix(content, "link::") {
|
||||||
|
content = base64.StdEncoding.EncodeToString([]byte(content))
|
||||||
|
}
|
||||||
|
return UploadInfo{
|
||||||
|
Name: fileNameWithoutSpaces,
|
||||||
|
SavePath: strings.TrimPrefix(savePath, appDir),
|
||||||
|
Content: content,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiUploadHandler 处理多文件上传请求
|
||||||
|
func MultiUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(10000 << 20); err != nil {
|
||||||
|
http.Error(w, "Failed to parse multipart form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := r.MultipartForm.File["files"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
http.Error(w, "No file parts in the request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfoList := make([]UploadInfo, 0, len(files))
|
||||||
|
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to open uploaded file", http.StatusBadRequest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read uploaded file", http.StatusBadRequest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
//log.Printf(string(content))
|
||||||
|
// 保存上传的文件内容
|
||||||
|
info, err := SaveContentToFile(string(content), fileHeader.Filename)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to save uploaded file", http.StatusBadRequest)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println(info.SavePath)
|
||||||
|
|
||||||
|
//info.SavePath = savePath
|
||||||
|
fileInfoList = append(fileInfoList, info)
|
||||||
|
}
|
||||||
|
user := UserInfo{
|
||||||
|
IP: r.FormValue("ip"),
|
||||||
|
Hostname: r.FormValue("hostname"),
|
||||||
|
}
|
||||||
|
msg := Message{
|
||||||
|
Type: "file",
|
||||||
|
Content: "file recieved",
|
||||||
|
SenderInfo: user,
|
||||||
|
FileList: fileInfoList,
|
||||||
|
}
|
||||||
|
messageChan <- msg
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "File send successfully")
|
||||||
|
//serv.Res(serv.Response{Code: 0, Data: fileInfoList}, w)
|
||||||
|
}
|
||||||
117
localchat/windiscovery.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package localchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartServiceDiscovery() {
|
||||||
|
// 解析多播地址
|
||||||
|
addr, err := net.ResolveUDPAddr("udp4", broadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error resolving multicast address:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听本地网络接口上的多播地址
|
||||||
|
conn, err := net.ListenMulticastUDP("udp4", nil, addr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error listening on multicast address:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
// 设置组播通信的网路接口
|
||||||
|
// intf,err := net.InterfaceByName("eth0")
|
||||||
|
// if err != nil {
|
||||||
|
// fmt.Println("Error getting network interface:", err)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// conn.setMulticastInterface(intf)
|
||||||
|
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, addr, err := conn.ReadFromUDP(buffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading from UDP: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("Received message: %s from %s\n", buffer[:n], addr)
|
||||||
|
|
||||||
|
var udpMsg UdpMessage
|
||||||
|
err = json.Unmarshal(buffer[:n], &udpMsg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error unmarshalling JSON: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Get message: %+v", udpMsg)
|
||||||
|
OnlineUsers[udpMsg.IP] = udpMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// findMulticastInterface 查找适合多播的网络接口
|
||||||
|
func findMulticastInterface() (*net.Interface, error) {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if strings.HasPrefix(iface.Name, "eth") || strings.HasPrefix(iface.Name, "en") || strings.HasPrefix(iface.Name, "wlan") {
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := addr.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
}
|
||||||
|
if ip != nil && !ip.IsLoopback() {
|
||||||
|
return &iface, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no suitable multicast interface found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupWindowsMulticast 为Windows平台设置多播
|
||||||
|
func setupWindowsMulticast(conn net.PacketConn, iface *net.Interface, multicastIP net.IP) error {
|
||||||
|
// 获取文件描述符
|
||||||
|
if sock, err := conn.(*net.UDPConn).File(); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
defer sock.Close()
|
||||||
|
fd := sock.Fd()
|
||||||
|
|
||||||
|
// 加入多播组
|
||||||
|
var mreq windows.RawSockaddrInet4
|
||||||
|
mreq.Family = windows.AF_INET
|
||||||
|
mreq.Port = 0
|
||||||
|
|
||||||
|
// 确保 multicastIP 是 IPv4 并转换为 [4]byte
|
||||||
|
ipv4 := multicastIP.To4()
|
||||||
|
if ipv4 == nil {
|
||||||
|
return errors.New("multicast IP is not an IPv4 address")
|
||||||
|
}
|
||||||
|
copy(mreq.Addr[:], ipv4)
|
||||||
|
|
||||||
|
if err := windows.SetsockoptIPv4MulticastInterface(fd, windows.IPMULTICAST_IF, &mreq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定到特定接口(可选,根据需求调整)
|
||||||
|
ifaceIndex := uint32(iface.Index)
|
||||||
|
if err := windows.BindToDevice(fd, windows.StringToUTF16Ptr(fmt.Sprintf("%d", ifaceIndex))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}*/
|
||||||
8
main.go
|
|
@ -3,6 +3,8 @@ package main
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
|
||||||
|
App "godoos/app"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
|
@ -13,7 +15,7 @@ var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := App.NewApp()
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
@ -24,8 +26,8 @@ func main() {
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.Startup,
|
||||||
//OnShutdown: app.shutdown,
|
OnShutdown: app.Shutdown,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
app,
|
||||||
},
|
},
|
||||||
|
|
|
||||||