支持多开实例隔离并默认打开小店
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
type instanceLock interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func osMkdirAll(path string) error {
|
||||
return os.MkdirAll(path, 0700)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user