支持多开实例隔离并默认打开小店

This commit is contained in:
yml2213
2026-08-09 17:25:18 +08:00
parent 6c881e9182
commit a9ad969b64
16 changed files with 385 additions and 27 deletions
+6 -3
View File
@@ -8,7 +8,7 @@ import { useLogs } from '@/composables/useLogs'
import { useActivity } from '@/composables/useActivity'
import AppShell from '@/components/layout/AppShell.vue'
import ToastContainer from '@/components/ToastContainer.vue'
import { GetTime, GetVersion } from '@wails/go/app/App'
import { GetInstanceName, GetTime, GetVersion } from '@wails/go/app/App'
import { WindowGetSize, WindowSetSize } from '@wails/runtime/runtime'
const { initTheme } = useTheme()
@@ -20,6 +20,7 @@ const { clearResults } = useLogs()
const { loadActivities } = useActivity()
const version = ref('')
const instanceName = ref('默认实例')
const serverTime = ref('--')
// 轮询定时器
@@ -91,7 +92,9 @@ onMounted(async () => {
// 获取版本号
try {
version.value = await GetVersion()
const [appVersion, currentInstanceName] = await Promise.all([GetVersion(), GetInstanceName()])
version.value = appVersion.split(' ')[0] || appVersion
instanceName.value = currentInstanceName || '默认实例'
} catch {}
// 清空后端旧结果
@@ -112,7 +115,7 @@ onUnmounted(() => {
</script>
<template>
<AppShell :version="version" :server-time="serverTime" />
<AppShell :version="version" :instance-name="instanceName" :server-time="serverTime" />
<ToastContainer />
</template>
+2 -1
View File
@@ -4,13 +4,14 @@ import RightConsole from './RightConsole.vue'
defineProps<{
version: string
instanceName: string
serverTime: string
}>()
</script>
<template>
<div class="app-shell">
<LeftSidebar :version="version" />
<LeftSidebar :version="version" :instance-name="instanceName" />
<RightConsole :server-time="serverTime" />
</div>
</template>
+45 -3
View File
@@ -9,6 +9,7 @@ import XiaodianTabs from '@/components/product/XiaodianTabs.vue'
defineProps<{
version: string
instanceName: string
}>()
const { loadProducts, productInfo, filteredProducts, loading } = useProducts()
@@ -28,11 +29,14 @@ const onSwitchActivity = async (e: Event) => {
<aside class="left">
<!-- 品牌区 -->
<div class="brand">
<div>
<div class="brand-content">
<h1>斗鱼抢购</h1>
<div class="brand-sub">实时库存 · 多账号 · 定时触发</div>
</div>
<span class="stat-pill">v{{ version }}</span>
<div class="brand-meta">
<span class="instance-pill">{{ instanceName }}</span>
<span class="stat-pill">v{{ version }}</span>
</div>
</div>
<!-- 活动选择 -->
@@ -98,18 +102,46 @@ const onSwitchActivity = async (e: Event) => {
.brand {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.brand-content {
flex: 1;
min-width: 0;
}
.brand h1 {
font-size: 15px;
font-weight: 600;
color: var(--text-strong);
letter-spacing: -.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.instance-pill {
padding: 3px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
color: var(--muted);
font-size: 11px;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-sub {
@@ -117,6 +149,16 @@ const onSwitchActivity = async (e: Event) => {
color: var(--muted);
margin-top: 3px;
letter-spacing: .02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-meta .stat-pill {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.brand + .activity-box {
+15 -5
View File
@@ -1,9 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { GetInstanceName } from '@wails/go/app/App'
import { installInstanceStorage } from './utils/instanceStorage'
const app = createApp(App)
const pinia = createPinia()
const bootstrap = async () => {
let instanceName = '默认实例'
try {
instanceName = await GetInstanceName() || instanceName
} catch {}
app.use(pinia)
app.mount('#app')
installInstanceStorage(instanceName)
const { default: App } = await import('./App.vue')
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
}
void bootstrap()
+26
View File
@@ -0,0 +1,26 @@
let installed = false
// WebView 的 localStorage 会在同一个应用标识下跨窗口共享;在加载界面前
// 为每个后端实例加前缀,避免选择、监控参数和窗口状态互相覆盖。
export const installInstanceStorage = (instanceName: string) => {
if (installed || typeof Storage === 'undefined') return
installed = true
const suffix = encodeURIComponent(instanceName || '默认实例')
const prefix = `douyu-go:${suffix}:`
const storage = Storage.prototype
const getItem = storage.getItem
const setItem = storage.setItem
const removeItem = storage.removeItem
const key = (value: string) => prefix + value
storage.getItem = function (name: string) {
return getItem.call(this, key(name))
}
storage.setItem = function (name: string, value: string) {
return setItem.call(this, key(name), value)
}
storage.removeItem = function (name: string) {
return removeItem.call(this, key(name))
}
}
+1 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.9.1
golang.org/x/image v0.42.0
golang.org/x/sys v0.33.0
)
require (
@@ -42,6 +43,5 @@ require (
github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+2 -2
View File
@@ -115,8 +115,8 @@ var registry = map[string]*Activity{
xiaodian.ID: xiaodian,
}
// DefaultActivityID 默认活动,新分支默认启用「电竞手册」。
const DefaultActivityID = "peacematch"
// DefaultActivityID 是新建配置时默认打开的活动。
const DefaultActivityID = "xiaodian"
// List 返回所有注册活动,按注册顺序(精英手册在前)。
func List() []*Activity {
+32 -5
View File
@@ -30,10 +30,12 @@ type App struct {
scheduler *seckill.Scheduler
logStore *logger.MemoryLogStore
traceMu sync.RWMutex
trace *trace.Recorder
paths types.AppPaths
font *truetype.Font
traceMu sync.RWMutex
trace *trace.Recorder
paths types.AppPaths
font *truetype.Font
instanceLease *config.InstanceLease
instanceName string
cfgMu sync.RWMutex
cfg types.Config
@@ -122,10 +124,23 @@ func New() *App {
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.paths = config.DefaultAppPaths()
lease, instanceErr := config.AcquireInstancePaths()
if instanceErr != nil {
a.paths = config.DefaultAppPaths()
a.instanceName = "默认实例"
} else {
a.paths = lease.Paths()
a.instanceLease = lease
a.instanceName = lease.Name()
}
configMigrated, configMigrationErr := config.MigrateLegacyFile(a.paths.ConfigFile, a.paths.LegacyConfigFile)
a.logStore = logger.NewMemoryLogStore(800)
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
if instanceErr != nil {
logger.LogEvent("系统", "warn", "实例隔离初始化失败,已回退默认目录: %v", instanceErr)
} else {
logger.LogEvent("系统", "success", "已启动%s,数据目录: %s", a.instanceName, a.paths.DataDir)
}
if configMigrationErr != nil {
logger.LogEvent("配置", "warn", "旧配置迁移失败: %s: %v", a.paths.LegacyConfigFile, configMigrationErr)
} else if configMigrated {
@@ -182,6 +197,10 @@ func (a *App) Shutdown(ctx context.Context) {
if a.closeLog != nil {
a.closeLog()
}
if a.instanceLease != nil {
a.instanceLease.Release()
a.instanceLease = nil
}
}
// GetVersion 返回应用版本号。
@@ -191,3 +210,11 @@ func (a *App) GetVersion() string {
}
return constants.Version
}
// GetInstanceName 返回当前自动分配的本地实例名称。
func (a *App) GetInstanceName() string {
if a.instanceName == "" {
return "默认实例"
}
return a.instanceName
}
+19 -4
View File
@@ -17,11 +17,22 @@ import (
// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。
// 默认数据存放在当前用户的应用配置目录,而非工作目录,避免 macOS 应用包目录无写权限。
func DefaultAppPaths() types.AppPaths {
dataDir := resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
return appPathsForDataDir(defaultDataDir(), true)
}
func defaultDataDir() string {
return resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
}
func appPathsForDataDir(dataDir string, useLegacyFiles bool) types.AppPaths {
configFile := filepath.Join(dataDir, constants.DefaultConfigFile)
accountFile := filepath.Join(dataDir, constants.DefaultAccountFile)
legacyConfigFile := resolvePath(constants.LegacyDefaultConfigFile)
legacyAccountFile := resolvePath(constants.LegacyDefaultAccountFile)
legacyConfigFile := ""
legacyAccountFile := ""
if useLegacyFiles {
legacyConfigFile = resolvePath(constants.LegacyDefaultConfigFile)
legacyAccountFile = resolvePath(constants.LegacyDefaultAccountFile)
}
if value := os.Getenv("DOUYU_CONFIG"); value != "" {
configFile = resolvePath(value)
@@ -51,12 +62,16 @@ func DefaultAppPaths() types.AppPaths {
// defaultLogDir 在 wails dev 中把日志放在项目工作目录,便于开发时直接查看;
// 正式包始终使用用户应用数据目录,避免写入应用包或受限目录。
func defaultLogDir(dataDir string) string {
if os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != "" {
if isWailsDev() {
return resolvePath(constants.DefaultLogDir)
}
return filepath.Join(dataDir, constants.DefaultLogDir)
}
func isWailsDev() bool {
return os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != ""
}
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, constants.DefaultAppDataDirName)
+97
View File
@@ -0,0 +1,97 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"douyu_go/internal/constants"
"douyu_go/internal/types"
)
const maxInstanceSlots = 256
// InstanceLease 代表当前进程持有的实例槽位;必须在程序退出时释放。
type InstanceLease struct {
paths types.AppPaths
name string
lock instanceLock
releaseOnce sync.Once
}
// AcquireInstancePaths 按顺序占用空闲实例。第一个进程使用默认实例,
// 后续进程分别使用实例 2、实例 3,以隔离账号、配置与运行数据。
func AcquireInstancePaths() (*InstanceLease, error) {
baseDir := defaultDataDir()
instanceRoot := filepath.Join(baseDir, "instances")
lockDir := filepath.Join(instanceRoot, ".locks")
if err := osMkdirAll(lockDir); err != nil {
return nil, fmt.Errorf("创建实例锁目录失败: %w", err)
}
for slot := 1; slot <= maxInstanceSlots; slot++ {
lock, acquired, err := tryAcquireInstanceLock(filepath.Join(lockDir, instanceLockFileName(slot)))
if err != nil {
return nil, fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
}
if !acquired {
continue
}
dataDir := baseDir
if slot > 1 {
dataDir = filepath.Join(instanceRoot, fmt.Sprintf("instance-%d", slot))
}
paths := appPathsForDataDir(dataDir, slot == 1)
if slot > 1 && isWailsDev() && os.Getenv("DOUYU_LOG_DIR") == "" {
paths.LogDir = resolvePath(filepath.Join(constants.DefaultLogDir, "instances", fmt.Sprintf("instance-%d", slot)))
}
return &InstanceLease{
paths: paths,
name: instanceName(slot),
lock: lock,
}, nil
}
return nil, fmt.Errorf("最多支持同时运行 %d 个实例", maxInstanceSlots)
}
// Paths 返回当前实例独立的数据路径。
func (l *InstanceLease) Paths() types.AppPaths {
if l == nil {
return types.AppPaths{}
}
return l.paths
}
// Name 返回面向用户的实例名称。
func (l *InstanceLease) Name() string {
if l == nil {
return ""
}
return l.name
}
// Release 释放实例锁,允许新启动的程序复用该实例。
func (l *InstanceLease) Release() {
if l == nil || l.lock == nil {
return
}
l.releaseOnce.Do(func() {
_ = l.lock.Close()
})
}
func instanceName(slot int) string {
if slot <= 1 {
return "默认实例"
}
return fmt.Sprintf("实例 %d", slot)
}
func instanceLockFileName(slot int) string {
if slot <= 1 {
return "default.lock"
}
return fmt.Sprintf("instance-%d.lock", slot)
}
+11
View File
@@ -0,0 +1,11 @@
package config
import "os"
type instanceLock interface {
Close() error
}
func osMkdirAll(path string) error {
return os.MkdirAll(path, 0700)
}
+36
View File
@@ -0,0 +1,36 @@
//go:build !windows
package config
import (
"errors"
"os"
"syscall"
)
type unixInstanceLock struct {
file *os.File
}
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, false, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
return nil, false, nil
}
return nil, false, err
}
return &unixInstanceLock{file: file}, true, nil
}
func (l *unixInstanceLock) Close() error {
if l == nil || l.file == nil {
return nil
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
return l.file.Close()
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package config
import (
"errors"
"golang.org/x/sys/windows"
)
type windowsInstanceLock struct {
handle windows.Handle
}
func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(path),
windows.GENERIC_READ|windows.GENERIC_WRITE,
0,
nil,
windows.OPEN_ALWAYS,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false, nil
}
return nil, false, err
}
return &windowsInstanceLock{handle: handle}, true, nil
}
func (l *windowsInstanceLock) Close() error {
if l == nil || l.handle == windows.InvalidHandle {
return nil
}
err := windows.CloseHandle(l.handle)
l.handle = windows.InvalidHandle
return err
}
+49
View File
@@ -0,0 +1,49 @@
package config
import (
"path/filepath"
"testing"
)
func TestAcquireInstancePathsSeparatesConcurrentInstances(t *testing.T) {
baseDir := t.TempDir()
t.Setenv("DOUYU_DATA_DIR", baseDir)
t.Setenv("DOUYU_CONFIG", "")
t.Setenv("DOUYU_USERS", "")
t.Setenv("DOUYU_LOG_DIR", "")
t.Setenv("DOUYU_SUCCESS_DIR", "")
t.Setenv("devserver", "")
t.Setenv("frontenddevserverurl", "")
first, err := AcquireInstancePaths()
if err != nil {
t.Fatal(err)
}
defer first.Release()
if first.Name() != "默认实例" || first.Paths().DataDir != baseDir {
t.Fatalf("first instance = %q, %q", first.Name(), first.Paths().DataDir)
}
second, err := AcquireInstancePaths()
if err != nil {
t.Fatal(err)
}
defer second.Release()
wantSecondDir := filepath.Join(baseDir, "instances", "instance-2")
if second.Name() != "实例 2" || second.Paths().DataDir != wantSecondDir {
t.Fatalf("second instance = %q, %q", second.Name(), second.Paths().DataDir)
}
if second.Paths().LegacyConfigFile != "" || second.Paths().LegacyAccountFile != "" {
t.Fatal("附加实例不应迁移默认实例或旧工作目录数据")
}
first.Release()
reused, err := AcquireInstancePaths()
if err != nil {
t.Fatal(err)
}
defer reused.Release()
if reused.Name() != "默认实例" || reused.Paths().DataDir != baseDir {
t.Fatalf("reused instance = %q, %q", reused.Name(), reused.Paths().DataDir)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
const Version = "0.1.7"
const Version = "0.1.9"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
"version": "0.1.7",
"version": "0.1.9",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",
@@ -15,6 +15,6 @@
"email": "yml@example.com"
},
"info": {
"productVersion": "0.1.7"
"productVersion": "0.1.9"
}
}