Initial commit: iOS Build Server
- FastAPI backend with build queue, WebSocket logs, task management - Vue 3 frontend with build/config/history views - Xcode project build automation with IPA export - Fix: initialize build_dir before try block to ensure cleanup on early failure
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iOS 自动打包服务</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3649
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "build-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<h1>iOS 自动打包服务</h1>
|
||||
<div class="header-right">
|
||||
<nav class="nav">
|
||||
<router-link to="/build">打包</router-link>
|
||||
<router-link to="/history">历史</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/admin">管理</router-link>
|
||||
</nav>
|
||||
<div v-if="!isLoggedIn" class="user-info">
|
||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||
</div>
|
||||
<div v-else class="user-info">
|
||||
<div class="user-avatar">A</div>
|
||||
<span class="user-name">管理员</span>
|
||||
<button class="btn-logout" @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<router-view />
|
||||
|
||||
<!-- 登录弹窗 -->
|
||||
<div v-if="showLogin" class="modal-overlay" @click.self="showLogin = false">
|
||||
<div class="login-modal">
|
||||
<h2>管理员登录</h2>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="loginForm.username" type="text" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<button class="login-btn" @click="login">登录</button>
|
||||
<div class="login-hint">默认账号: admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
const showLogin = ref(false)
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(loginForm.value),
|
||||
})
|
||||
if (res.ok) {
|
||||
isLoggedIn.value = true
|
||||
showLogin.value = false
|
||||
localStorage.setItem('isAdmin', 'true')
|
||||
} else {
|
||||
alert('用户名或密码错误')
|
||||
}
|
||||
} catch (e) {
|
||||
alert('登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('isAdmin')
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
if (localStorage.getItem('isAdmin') === 'true') {
|
||||
isLoggedIn.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
|
||||
.header { background: #1a1a2e; color: white; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.header-right { display: flex; align-items: center; gap: 20px; }
|
||||
.nav { display: flex; gap: 20px; }
|
||||
.nav a { color: #a0a0a0; text-decoration: none; padding: 8px 16px; border-radius: 6px; }
|
||||
.nav a.router-link-active { background: #16213e; color: white; }
|
||||
.nav a:hover { color: white; }
|
||||
.user-info { display: flex; align-items: center; gap: 10px; }
|
||||
.user-avatar { width: 32px; height: 32px; background: #1890ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.user-name { font-size: 14px; }
|
||||
.btn-login { background: #1890ff; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.btn-logout { background: transparent; color: #a0a0a0; border: 1px solid #444; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.btn-logout:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.login-modal { background: white; border-radius: 12px; padding: 32px; width: 400px; }
|
||||
.login-modal h2 { font-size: 20px; margin-bottom: 24px; text-align: center; color: #1a1a2e; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group input:focus { outline: none; border-color: #1890ff; }
|
||||
.login-btn { width: 100%; padding: 12px; background: #1890ff; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 8px; }
|
||||
.login-btn:hover { background: #40a9ff; }
|
||||
.login-hint { text-align: center; margin-top: 16px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import App from '../App.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn(k => store[k] || null),
|
||||
setItem: vi.fn((k, v) => { store[k] = v }),
|
||||
removeItem: vi.fn(k => { delete store[k] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
|
||||
|
||||
function createMockRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
}
|
||||
|
||||
describe('App.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.getItem.mockImplementation(k => null)
|
||||
})
|
||||
|
||||
it('显示标题', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('iOS 自动打包服务')
|
||||
})
|
||||
|
||||
it('未登录时显示登录按钮', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('已登录时显示退出按钮', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.find('.btn-logout').exists()).toBe(true)
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击登录按钮弹出登录框', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
expect(wrapper.find('.login-modal').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('登录成功后更新状态', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'admin-token', is_admin: true }),
|
||||
})
|
||||
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
await wrapper.find('.login-modal input[type="text"]').setValue('admin')
|
||||
await wrapper.find('.login-modal input[type="password"]').setValue('admin123')
|
||||
await wrapper.find('.login-btn').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.btn-logout').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('登录失败显示错误', async () => {
|
||||
fetch.mockResolvedValueOnce({ ok: false })
|
||||
window.alert = vi.fn()
|
||||
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-login').trigger('click')
|
||||
await wrapper.find('.login-modal input[type="text"]').setValue('admin')
|
||||
await wrapper.find('.login-modal input[type="password"]').setValue('wrong')
|
||||
await wrapper.find('.login-btn').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith('用户名或密码错误')
|
||||
})
|
||||
|
||||
it('点击退出清除登录状态', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('.btn-logout').trigger('click')
|
||||
expect(wrapper.find('.btn-login').exists()).toBe(true)
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('isAdmin')
|
||||
})
|
||||
|
||||
it('已登录时显示管理导航', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('.nav a')
|
||||
const texts = links.map(l => l.text())
|
||||
expect(texts).toContain('管理')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock WebSocket as a class
|
||||
class MockWebSocket {
|
||||
constructor(url) { this.url = url; this.onmessage = null; }
|
||||
close() {}
|
||||
}
|
||||
global.WebSocket = MockWebSocket
|
||||
|
||||
describe('BuildView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('显示打包配置面板', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('打包配置')
|
||||
expect(wrapper.text()).toContain('选择 App')
|
||||
expect(wrapper.text()).toContain('打包类型')
|
||||
expect(wrapper.text()).toContain('代码分支')
|
||||
expect(wrapper.text()).toContain('代码混淆')
|
||||
})
|
||||
|
||||
it('加载 apps 和 schemes', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const appOptions = wrapper.findAll('select')[0].findAll('option')
|
||||
expect(appOptions.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('加载分支列表并显示下拉', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const branchSelect = wrapper.findAll('select').find(s => {
|
||||
const options = s.findAll('option')
|
||||
return options.some(o => o.text() === 'main')
|
||||
})
|
||||
expect(branchSelect).toBeTruthy()
|
||||
})
|
||||
|
||||
it('默认值正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.form.build_type).toBe('Ad_Hoc')
|
||||
expect(wrapper.vm.form.obfuscation).toBe(false)
|
||||
expect(wrapper.vm.form.branch).toBe('main')
|
||||
})
|
||||
|
||||
it('提交打包任务', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/tasks' && opts?.method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'test-id', status: 'pending' }),
|
||||
})
|
||||
}
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setData({ form: { ...wrapper.vm.form, app_id: '1' } })
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
|
||||
it('未选择 App 时提示', async () => {
|
||||
window.alert = vi.fn()
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.btn-primary').trigger('click')
|
||||
expect(window.alert).toHaveBeenCalledWith('请选择 App')
|
||||
})
|
||||
|
||||
it('显示任务队列', async () => {
|
||||
fetch.mockImplementation((url) => {
|
||||
const responses = {
|
||||
'/api/apps': { '1': { name: '测试App', server: '测试环境' } },
|
||||
'/api/schemes': { '1': { name: 'testScheme' } },
|
||||
'/api/branches': ['main', 'dev'],
|
||||
'/api/tasks': [
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch', status: 'pending', created_at: '2024-01-01T00:00:00' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch', status: 'completed', created_at: '2024-01-01T00:00:00' },
|
||||
],
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
})
|
||||
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
const taskItems = wrapper.findAll('.task-item')
|
||||
expect(taskItems.length).toBe(1) // 只显示未完成的任务(pending),completed 已过滤
|
||||
})
|
||||
|
||||
it('状态文本正确', async () => {
|
||||
const wrapper = mount(BuildView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||
expect(wrapper.vm.statusText('running')).toBe('打包中')
|
||||
expect(wrapper.vm.statusText('completed')).toBe('已完成')
|
||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||
expect(wrapper.vm.statusText('cancelled')).toBe('已取消')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {}
|
||||
return {
|
||||
getItem: vi.fn(k => store[k] || null),
|
||||
setItem: vi.fn((k, v) => { store[k] = v }),
|
||||
removeItem: vi.fn(k => { delete store[k] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
|
||||
|
||||
function mockFetch(url) {
|
||||
const responses = {
|
||||
'/api/config/apps': { '1': { name: '测试App', server: '测试环境', AppGuid: 'guid' } },
|
||||
'/api/config/schemes': { '1': { name: 'testScheme', ossFloder: 'test' } },
|
||||
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
|
||||
'/api/config/branches': ['main', 'dev'],
|
||||
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
|
||||
'/api/config': { apps: {}, schemes: {}, branches: ['main'] },
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(responses[url] || {}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('ConfigView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
fetch.mockImplementation(mockFetch)
|
||||
})
|
||||
|
||||
it('未登录时显示权限提示', async () => {
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('需要管理员权限')
|
||||
})
|
||||
|
||||
it('已登录时显示管理页面', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('配置管理')
|
||||
})
|
||||
|
||||
it('显示侧边栏菜单', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const menuItems = wrapper.findAll('.sidebar-menu li')
|
||||
const texts = menuItems.map(li => li.text())
|
||||
expect(texts).toContain('服务器环境')
|
||||
expect(texts).toContain('Apps 配置')
|
||||
expect(texts).toContain('Schemes 配置')
|
||||
expect(texts).toContain('分支管理')
|
||||
expect(texts).toContain('打包设置')
|
||||
})
|
||||
|
||||
it('默认显示服务器环境 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('服务器环境配置')
|
||||
})
|
||||
|
||||
it('切换到分支管理 tab', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('main')
|
||||
expect(wrapper.text()).toContain('dev')
|
||||
})
|
||||
|
||||
it('添加分支', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/config/branches' && opts?.method === 'POST') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const input = wrapper.find('input[placeholder="输入分支名称"]')
|
||||
await input.setValue('new-branch')
|
||||
|
||||
const addBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '添加分支')
|
||||
await addBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/config/branches', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
|
||||
it('删除分支', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url.includes('/api/config/branches/') && opts?.method === 'DELETE') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
|
||||
await branchesMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const deleteBtns = wrapper.findAll('.action-btn.delete')
|
||||
if (deleteBtns.length > 0) {
|
||||
await deleteBtns[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(window.confirm).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('保存打包设置', async () => {
|
||||
fetch.mockImplementation((url, opts) => {
|
||||
if (url === '/api/config/build' && opts?.method === 'PUT') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'ok' }) })
|
||||
}
|
||||
return mockFetch(url)
|
||||
})
|
||||
window.alert = vi.fn()
|
||||
|
||||
localStorageMock.getItem.mockReturnValue('true')
|
||||
const wrapper = mount(ConfigView)
|
||||
await flushPromises()
|
||||
|
||||
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
|
||||
await buildMenu.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const saveBtn = wrapper.findAll('.btn-primary').find(b => b.text() === '保存设置')
|
||||
await saveBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(window.alert).toHaveBeenCalledWith('设置已保存')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
|
||||
global.fetch = vi.fn()
|
||||
window.open = vi.fn()
|
||||
|
||||
const mockTasks = [
|
||||
{ id: '1', app_name: 'App1', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'completed', created_at: '2024-01-01T10:00:00', dsym_path: '/path/dsym', oss_url: 'https://oss.com/app1.ipa', qr_code_path: '/path/qr.png' },
|
||||
{ id: '2', app_name: 'App2', build_type: 'App_Store', scheme_name: 'sch2', status: 'failed', created_at: '2024-01-02T10:00:00', error_message: '构建失败' },
|
||||
{ id: '3', app_name: 'App3', build_type: 'Ad_Hoc', scheme_name: 'sch1', status: 'pending', created_at: '2024-01-03T10:00:00' },
|
||||
]
|
||||
|
||||
function createMockRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/build', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('HistoryView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetch.mockResolvedValue({
|
||||
json: () => Promise.resolve(mockTasks),
|
||||
})
|
||||
})
|
||||
|
||||
it('显示打包历史标题', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('打包历史')
|
||||
})
|
||||
|
||||
it('加载并显示任务列表', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2) // App2(failed) 默认隐藏
|
||||
})
|
||||
|
||||
it('显示任务信息', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('App1')
|
||||
// App2 是 failed 状态,默认隐藏
|
||||
expect(wrapper.text()).not.toContain('App2')
|
||||
expect(wrapper.text()).toContain('Ad_Hoc')
|
||||
})
|
||||
|
||||
it('默认隐藏失败和已取消的任务', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// 只显示 App1(completed)和 App3(pending),App2(failed)被隐藏
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2)
|
||||
expect(wrapper.text()).toContain('App1')
|
||||
expect(wrapper.text()).toContain('App3')
|
||||
expect(wrapper.text()).toContain('等待中')
|
||||
})
|
||||
|
||||
it('按打包类型过滤', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[0].setValue('Ad_Hoc')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2) // App1 和 App3
|
||||
})
|
||||
|
||||
it('按状态过滤', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const selects = wrapper.findAll('.filter-select')
|
||||
await selects[1].setValue('failed')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(1) // App2
|
||||
})
|
||||
|
||||
it('已完成任务显示下载按钮', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// 第一行(App1, completed)应该有 dSYM 和下载按钮
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]
|
||||
const buttons = firstRow.findAll('.action-btn')
|
||||
const buttonTexts = buttons.map(b => b.text())
|
||||
expect(buttonTexts).toContain('dSYM')
|
||||
expect(buttonTexts).toContain('下载')
|
||||
expect(buttonTexts).toContain('二维码')
|
||||
})
|
||||
|
||||
it('空列表显示提示', async () => {
|
||||
fetch.mockResolvedValue({ json: () => Promise.resolve([]) })
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('暂无打包记录')
|
||||
})
|
||||
|
||||
it('状态文本正确', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.statusText('pending')).toBe('等待中')
|
||||
expect(wrapper.vm.statusText('running')).toBe('打包中')
|
||||
expect(wrapper.vm.statusText('completed')).toBe('已完成')
|
||||
expect(wrapper.vm.statusText('failed')).toBe('失败')
|
||||
})
|
||||
|
||||
it('点击查看日志跳转', async () => {
|
||||
const router = createMockRouter()
|
||||
router.push = vi.fn()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const logBtn = wrapper.findAll('.action-btn').find(b => b.text() === '日志')
|
||||
await logBtn.trigger('click')
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith({ path: '/build', query: { taskId: '1' } })
|
||||
})
|
||||
|
||||
it('点击二维码弹出弹窗', async () => {
|
||||
const router = createMockRouter()
|
||||
const wrapper = mount(HistoryView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const qrBtn = wrapper.findAll('.action-btn').find(b => b.text() === '二维码')
|
||||
await qrBtn.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.qr-modal').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('下载二维码')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useWebSocket(taskId) {
|
||||
const logs = ref([])
|
||||
const connected = ref(false)
|
||||
let ws = null
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.level !== 'heartbeat') {
|
||||
logs.value.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
connected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(disconnect)
|
||||
|
||||
return { logs, connected, connect, disconnect }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import BuildView from '../views/BuildView.vue'
|
||||
import HistoryView from '../views/HistoryView.vue'
|
||||
import ConfigView from '../views/ConfigView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/build' },
|
||||
{ path: '/build', name: 'Build', component: BuildView },
|
||||
{ path: '/history', name: 'History', component: HistoryView },
|
||||
{ path: '/admin', name: 'Admin', component: ConfigView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="build-page">
|
||||
<!-- 左侧配置面板 -->
|
||||
<div class="config-panel">
|
||||
<h2>打包配置</h2>
|
||||
<div class="form-group">
|
||||
<label>选择 App</label>
|
||||
<select v-model="form.app_id">
|
||||
<option value="">请选择...</option>
|
||||
<option v-for="(app, id) in apps" :key="id" :value="id">
|
||||
{{ app.server }} - {{ app.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包类型</label>
|
||||
<select v-model="form.build_type">
|
||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
||||
<option value="App_Store">App_Store</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>选择 Scheme</label>
|
||||
<select v-model="form.scheme_id">
|
||||
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
|
||||
{{ scheme.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>代码分支</label>
|
||||
<select v-model="form.branch">
|
||||
<option v-for="b in branches" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>代码混淆</label>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="obfuscation" v-model="form.obfuscation">
|
||||
<label for="obfuscation">启用混淆</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="submitTask" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : '开始打包' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧日志和任务列表 -->
|
||||
<div class="right-panel">
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<h3>实时日志 {{ currentTaskId ? `- ${currentTaskId}` : '' }}</h3>
|
||||
<button v-if="currentTaskId" class="btn btn-danger" style="width: auto; padding: 6px 12px;" @click="cancelTask">
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
<div class="log-content" ref="logContainer">
|
||||
<div v-for="(log, i) in logs" :key="i" :class="['log-line', log.level]">
|
||||
[{{ formatTime(log.timestamp) }}] {{ log.message }}
|
||||
</div>
|
||||
<div v-if="!logs.length && !currentTaskId" class="log-line info" style="color: #666;">
|
||||
选择任务或创建新任务查看日志
|
||||
</div>
|
||||
<div v-else-if="!logs.length" class="log-line info" style="color: #666;">
|
||||
正在连接日志流...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打包结果 -->
|
||||
<div v-if="completedTask" class="result-panel">
|
||||
<h3>打包结果</h3>
|
||||
<div class="result-info">
|
||||
<div class="result-row">
|
||||
<span class="result-label">App</span>
|
||||
<span>{{ completedTask.app_name }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">类型</span>
|
||||
<span>{{ completedTask.build_type }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">状态</span>
|
||||
<span :class="['task-status', `status-${completedTask.status}`]">{{ statusText(completedTask.status) }}</span>
|
||||
</div>
|
||||
<div v-if="completedTask.error_message" class="result-row">
|
||||
<span class="result-label">错误信息</span>
|
||||
<span class="error-msg">{{ completedTask.error_message }}</span>
|
||||
</div>
|
||||
<div v-if="completedTask.oss_url" class="result-row">
|
||||
<span class="result-label">下载链接</span>
|
||||
<a :href="completedTask.oss_url" target="_blank" class="download-link">点击下载 IPA</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="completedTask.qr_code_path" class="qr-section">
|
||||
<img :src="`/api/tasks/${completedTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<p class="qr-hint">扫码下载安装</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<h3>任务队列</h3>
|
||||
<div v-for="task in tasks" :key="task.id" class="task-item" @click="selectTask(task.id)">
|
||||
<div class="task-info">
|
||||
<div class="app-name">{{ task.app_name }}</div>
|
||||
<div class="task-meta">{{ task.build_type }} | {{ task.scheme_name }} | {{ formatTime(task.created_at) }}</div>
|
||||
</div>
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
</div>
|
||||
<div v-if="!tasks.length" style="text-align: center; color: #999; padding: 20px;">
|
||||
暂无任务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||||
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
const branches = ref(['main'])
|
||||
const tasks = ref([])
|
||||
const form = ref({
|
||||
app_id: '',
|
||||
build_type: 'Ad_Hoc',
|
||||
scheme_id: '1',
|
||||
obfuscation: false,
|
||||
branch: 'main',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const currentTaskId = ref(null)
|
||||
const completedTask = ref(null)
|
||||
const logs = ref([])
|
||||
const logContainer = ref(null)
|
||||
let activeWs = null
|
||||
|
||||
const fetchTaskDetail = async (taskId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}`)
|
||||
if (res.ok) {
|
||||
completedTask.value = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const connectWs = (taskId) => {
|
||||
if (activeWs) {
|
||||
activeWs.close()
|
||||
activeWs = null
|
||||
}
|
||||
logs.value = []
|
||||
completedTask.value = null
|
||||
if (!taskId) return
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
||||
activeWs = ws
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.level !== 'heartbeat') {
|
||||
logs.value.push(msg)
|
||||
nextTick(() => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
// 检测打包完成或失败,获取任务详情
|
||||
if (msg.level === 'step' && msg.message.includes('打包完成')) {
|
||||
fetchTaskDetail(taskId)
|
||||
refreshTasks()
|
||||
}
|
||||
if (msg.level === 'error' || (msg.level === 'step' && msg.message.includes('打包失败'))) {
|
||||
fetchTaskDetail(taskId)
|
||||
refreshTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => currentTaskId.value, (newId) => {
|
||||
connectWs(newId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (activeWs) {
|
||||
activeWs.close()
|
||||
activeWs = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
|
||||
fetch('/api/apps'),
|
||||
fetch('/api/schemes'),
|
||||
fetch('/api/branches'),
|
||||
fetch('/api/tasks'),
|
||||
])
|
||||
apps.value = await appsRes.json()
|
||||
schemes.value = await schemesRes.json()
|
||||
branches.value = await branchesRes.json()
|
||||
const allTasks = await tasksRes.json()
|
||||
tasks.value = allTasks.filter(t =>
|
||||
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
|
||||
)
|
||||
// 确保默认分支在列表中
|
||||
if (!branches.value.includes(form.value.branch)) {
|
||||
form.value.branch = branches.value[0] || 'main'
|
||||
}
|
||||
})
|
||||
|
||||
const submitTask = async () => {
|
||||
if (!form.value.app_id) {
|
||||
alert('请选择 App')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) {
|
||||
const task = await res.json()
|
||||
currentTaskId.value = task.id
|
||||
tasks.value.unshift(task)
|
||||
} else {
|
||||
alert('提交失败')
|
||||
}
|
||||
} catch (e) {
|
||||
alert('提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectTask = (taskId) => {
|
||||
currentTaskId.value = taskId
|
||||
completedTask.value = null
|
||||
}
|
||||
|
||||
const cancelTask = async () => {
|
||||
if (!currentTaskId.value) return
|
||||
if (!confirm('确定取消当前任务?')) return
|
||||
const res = await fetch(`/api/tasks/${currentTaskId.value}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
alert(err.detail || '取消失败')
|
||||
}
|
||||
refreshTasks()
|
||||
// 刷新任务详情以更新状态
|
||||
if (completedTask.value?.id === currentTaskId.value) {
|
||||
fetchTaskDetail(currentTaskId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshTasks = async () => {
|
||||
const res = await fetch('/api/tasks?limit=50')
|
||||
const all = await res.json()
|
||||
// 显示未完成的任务 + 当前选中的任务
|
||||
tasks.value = all.filter(t =>
|
||||
t.status === 'pending' || t.status === 'running' || t.id === currentTaskId.value
|
||||
)
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
return d.toLocaleTimeString()
|
||||
}
|
||||
|
||||
const statusText = (s) => {
|
||||
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.build-page { display: grid; grid-template-columns: 360px 1fr; gap: 24px; }
|
||||
.config-panel { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.config-panel h2 { font-size: 16px; margin-bottom: 16px; color: #1a1a2e; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group select, .form-group input { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group select:focus { outline: none; border-color: #1890ff; }
|
||||
.checkbox-group { display: flex; align-items: center; gap: 8px; }
|
||||
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.btn-primary:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.btn-danger { background: #ff4d4f; color: white; }
|
||||
|
||||
.right-panel { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 120px); overflow: hidden; }
|
||||
.log-panel { background: #1a1a2e; border-radius: 12px; flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.log-header { padding: 12px 16px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; }
|
||||
.log-header h3 { color: #a0a0a0; font-size: 14px; }
|
||||
.log-content { flex: 1; padding: 16px; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; color: #00ff00; overflow-y: auto; line-height: 1.6; }
|
||||
.log-line { margin-bottom: 4px; }
|
||||
.log-line.info { color: #00ff00; }
|
||||
.log-line.warn { color: #ffcc00; }
|
||||
.log-line.error { color: #ff4d4f; }
|
||||
.log-line.step { color: #1890ff; font-weight: bold; }
|
||||
|
||||
.task-list { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); max-height: 300px; overflow-y: auto; }
|
||||
.task-list h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
|
||||
.task-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 8px; cursor: pointer; }
|
||||
.task-item:hover { border-color: #1890ff; }
|
||||
.task-info { flex: 1; }
|
||||
.task-info .app-name { font-weight: 500; color: #1a1a2e; }
|
||||
.task-info .task-meta { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-pending { background: #f0f0f0; color: #666; }
|
||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||
.status-completed { background: #f6ffed; color: #52c41a; }
|
||||
.status-failed { background: #fff2f0; color: #ff4d4f; }
|
||||
.status-cancelled { background: #f0f0f0; color: #999; }
|
||||
|
||||
.result-panel { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.result-panel h3 { font-size: 14px; margin-bottom: 12px; color: #1a1a2e; }
|
||||
.result-info { margin-bottom: 16px; }
|
||||
.result-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
||||
.result-row:last-child { border-bottom: none; }
|
||||
.result-label { width: 80px; color: #999; font-size: 13px; flex-shrink: 0; }
|
||||
.download-link { color: #1890ff; text-decoration: none; }
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.qr-section { text-align: center; padding: 16px; background: #fafafa; border-radius: 8px; }
|
||||
.qr-image { width: 180px; height: 180px; border: 1px solid #f0f0f0; border-radius: 8px; }
|
||||
.qr-hint { margin-top: 8px; font-size: 13px; color: #999; }
|
||||
.error-msg { color: #ff4d4f; font-size: 13px; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="!isLoggedIn" class="access-denied">
|
||||
<h3>需要管理员权限</h3>
|
||||
<p>请先登录管理员账号以访问配置管理页面</p>
|
||||
<button class="btn-login" @click="$root.showLogin = true">登录管理员账号</button>
|
||||
</div>
|
||||
<div v-else class="admin-page">
|
||||
<div class="sidebar">
|
||||
<h3>配置管理</h3>
|
||||
<ul class="sidebar-menu">
|
||||
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
||||
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
||||
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<!-- 服务器环境配置 -->
|
||||
<div v-if="tab === 'servers'">
|
||||
<h2>服务器环境配置</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理服务器环境和对应的 API 地址、关联域名等配置</p>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openServerModal()">+ 新增环境</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>环境名称</th>
|
||||
<th>API 地址</th>
|
||||
<th>Associated Domains</th>
|
||||
<th>Universal Link</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(server, name) in servers" :key="name">
|
||||
<td><strong>{{ name }}</strong></td>
|
||||
<td>{{ server.api }}</td>
|
||||
<td>{{ server.assDom }}</td>
|
||||
<td>{{ server.universalLink }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openServerModal(name, server)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteServer(name)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Apps 配置 -->
|
||||
<div v-if="tab === 'apps'">
|
||||
<h2>Apps 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openAppModal()">+ 新增 App</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>环境</th>
|
||||
<th>AppGuid</th>
|
||||
<th>证书</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(app, id) in apps" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ app.name }}</td>
|
||||
<td>{{ app.server }}</td>
|
||||
<td>{{ app.AppGuid || app.AppId }}</td>
|
||||
<td>{{ Object.keys(app.certificates || {}).join(', ') || '-' }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openAppModal(id, app)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteApp(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Schemes 配置 -->
|
||||
<div v-if="tab === 'schemes'">
|
||||
<h2>Schemes 配置</h2>
|
||||
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openSchemeModal()">+ 新增 Scheme</button>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>OSS 目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(scheme, id) in schemes" :key="id">
|
||||
<td>{{ id }}</td>
|
||||
<td>{{ scheme.name }}</td>
|
||||
<td>{{ scheme.ossFloder }}</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="openSchemeModal(id, scheme)">编辑</button>
|
||||
<button class="action-btn delete" @click="deleteScheme(id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分支管理 -->
|
||||
<div v-if="tab === 'branches'">
|
||||
<h2>分支管理</h2>
|
||||
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理可打包的代码分支,打包时从对应分支目录获取源码</p>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
|
||||
<input v-model="newBranch" type="text" placeholder="输入分支名称" style="flex: 1; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px;">
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 24px;" @click="addBranch">添加分支</button>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分支名称</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in branches" :key="b">
|
||||
<td><strong>{{ b }}</strong></td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn delete" @click="deleteBranch(b)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!branches.length">
|
||||
<td colspan="2" style="text-align: center; color: #999;">暂无分支配置</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 上传配置 -->
|
||||
<div v-if="tab === 'upload'">
|
||||
<h2>上传配置</h2>
|
||||
<div style="max-width: 600px;">
|
||||
<div class="form-group">
|
||||
<label>上传方式</label>
|
||||
<select v-model="uploadConfig.mode">
|
||||
<option value="oss">阿里云 OSS</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- OSS 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'oss'" class="config-section">
|
||||
<h4 class="section-title">阿里云 OSS</h4>
|
||||
<div class="form-group">
|
||||
<label>AccessKey ID</label>
|
||||
<input v-model="uploadConfig.oss.access_key_id" type="text" placeholder="LTAI...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>AccessKey Secret</label>
|
||||
<input v-model="uploadConfig.oss.access_key_secret" type="password" placeholder="密钥">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint</label>
|
||||
<input v-model="uploadConfig.oss.endpoint" type="text" placeholder="oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bucket 名称</label>
|
||||
<input v-model="uploadConfig.oss.bucket_name" type="text" placeholder="my-bucket">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>访问地址(Base URL)</label>
|
||||
<input v-model="uploadConfig.oss.base_url" type="text" placeholder="https://my-bucket.oss-cn-beijing.aliyuncs.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV 配置 -->
|
||||
<div v-if="uploadConfig.mode === 'webdav'" class="config-section">
|
||||
<h4 class="section-title">WebDAV</h4>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="uploadConfig.webdav.server_url" type="text" placeholder="https://dav.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="uploadConfig.webdav.username" type="text" placeholder="留空则无需认证">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="uploadConfig.webdav.password" type="password" placeholder="密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>远程路径</label>
|
||||
<input v-model="uploadConfig.webdav.base_path" type="text" placeholder="/ios-builds">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>公开访问地址</label>
|
||||
<input v-model="uploadConfig.webdav.public_url" type="text" placeholder="https://download.example.com/ios-builds">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">iOS 安装需要 HTTPS 公开链接,需配置 Nginx 反代或公开目录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 钉钉通知 -->
|
||||
<div class="config-section">
|
||||
<h4 class="section-title">钉钉通知</h4>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="uploadConfig.dingtalk.enabled" style="width: 16px; height: 16px;">
|
||||
启用钉钉通知
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="uploadConfig.dingtalk.enabled">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input v-model="uploadConfig.dingtalk.webhook_url" type="text" placeholder="https://oapi.dingtalk.com/robot/send?access_token=...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>加签密钥(可选)</label>
|
||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveUploadConfig">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打包设置 -->
|
||||
<div v-if="tab === 'build'">
|
||||
<h2>打包设置</h2>
|
||||
<div style="max-width: 500px;">
|
||||
<div class="form-group">
|
||||
<label>最大并行打包数</label>
|
||||
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4,过高可能影响构建稳定性</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包目录保留时间(小时)</label>
|
||||
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>打包基础目录</label>
|
||||
<input type="text" v-model="buildSettings.build_base_dir">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON 编辑 -->
|
||||
<div v-if="tab === 'json'">
|
||||
<h2>JSON 编辑</h2>
|
||||
<div style="background: #1a1a2e; border-radius: 8px; padding: 16px;">
|
||||
<textarea v-model="jsonContent" style="width: 100%; height: 500px; background: transparent; border: none; color: #00ff00; font-family: 'Monaco', monospace; font-size: 12px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
<div style="margin-top: 16px; display: flex; gap: 12px;">
|
||||
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveJson">保存 JSON</button>
|
||||
<button class="action-btn" style="padding: 10px 24px;" @click="formatJson">格式化</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务器环境编辑弹窗 -->
|
||||
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingServerName ? '编辑' : '新增' }} 服务器环境</h3>
|
||||
<button class="modal-close" @click="showServerModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>环境名称 *</label>
|
||||
<input v-model="serverForm.name" type="text" placeholder="例如:测试环境" :disabled="!!editingServerName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="serverForm.api" type="text" placeholder="https://api3-dev.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="serverForm.assDom" type="text" placeholder="applinks:dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="serverForm.universalLink" type="text" placeholder="https://dev-data1.readoor.cn">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showServerModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveServer">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App 编辑弹窗 -->
|
||||
<div v-if="showAppModal" class="modal-overlay" @click.self="showAppModal = false">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingAppId ? '编辑' : '新增' }} App</h3>
|
||||
<button class="modal-close" @click="showAppModal = false">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<h4 class="section-title">基本信息</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>App 名称 *</label>
|
||||
<input v-model="appForm.name" type="text" placeholder="例如:上财云津">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>服务器环境 *</label>
|
||||
<select v-model="appForm.server" @change="onServerChange">
|
||||
<option value="">请选择环境</option>
|
||||
<option v-for="(server, name) in servers" :key="name" :value="name">
|
||||
{{ name }} - {{ server.api }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>AppGuid *</label>
|
||||
<input v-model="appForm.AppGuid" type="text" placeholder="应用唯一标识">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API 地址 *</label>
|
||||
<input v-model="appForm.API" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联配置 -->
|
||||
<h4 class="section-title">关联配置</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Associated Domains</label>
|
||||
<input v-model="appForm.AssDom" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Universal Link</label>
|
||||
<input v-model="appForm.UniversalLink" type="text" placeholder="根据环境自动填充" :disabled="!!appForm.server">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>微信登录 AppID</label>
|
||||
<input v-model="appForm.weixinlogin" type="text" placeholder="wx...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>微信支付 AppID</label>
|
||||
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>腾讯 AppID</label>
|
||||
<input v-model="appForm.tencent" type="text" placeholder="tencent...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>阿里云 License Key</label>
|
||||
<input v-model="appForm.AlivcLicenseKey" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 证书配置 -->
|
||||
<h4 class="section-title">证书配置</h4>
|
||||
<div v-for="certType in ['Ad_Hoc', 'App_Store']" :key="certType" class="cert-section">
|
||||
<div class="cert-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" :checked="appForm.certificates && appForm.certificates[certType]" @change="toggleCert(certType)">
|
||||
{{ certType }} 证书
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="appForm.certificates && appForm.certificates[certType]" class="cert-fields">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Bundle ID</label>
|
||||
<input v-model="appForm.certificates[certType].name" type="text" placeholder="cn.example.app">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>证书名称</label>
|
||||
<input v-model="appForm.certificates[certType].cer" type="text" placeholder="iPhone Distribution: ...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Provisioning Profile 路径</label>
|
||||
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题目录</label>
|
||||
<input v-model="appForm.certificates[certType].theme" type="text" placeholder="AutoPacking/ymh">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showAppModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveApp">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheme 编辑弹窗 -->
|
||||
<div v-if="showSchemeModal" class="modal-overlay" @click.self="showSchemeModal = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editingSchemeId ? '编辑' : '新增' }} Scheme</h3>
|
||||
<button class="modal-close" @click="showSchemeModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scheme 名称 *</label>
|
||||
<input v-model="schemeForm.name" type="text" placeholder="例如:readoor31">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>OSS 目录 *</label>
|
||||
<input v-model="schemeForm.ossFloder" type="text" placeholder="例如:readoor">
|
||||
</div>
|
||||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button class="action-btn" style="padding: 8px 16px;" @click="showSchemeModal = false">取消</button>
|
||||
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="saveScheme">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isLoggedIn = ref(localStorage.getItem('isAdmin') === 'true')
|
||||
const tab = ref('servers')
|
||||
const apps = ref({})
|
||||
const schemes = ref({})
|
||||
const servers = ref({})
|
||||
const branches = ref([])
|
||||
const newBranch = ref('')
|
||||
const buildSettings = ref({})
|
||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||
const jsonContent = ref('{}')
|
||||
|
||||
const showServerModal = ref(false)
|
||||
const editingServerName = ref(null)
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '' })
|
||||
|
||||
const showAppModal = ref(false)
|
||||
const editingAppId = ref(null)
|
||||
const appForm = ref({})
|
||||
|
||||
const showSchemeModal = ref(false)
|
||||
const editingSchemeId = ref(null)
|
||||
const schemeForm = ref({})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes] = await Promise.all([
|
||||
fetch('/api/config/apps'),
|
||||
fetch('/api/config/schemes'),
|
||||
fetch('/api/config/servers'),
|
||||
fetch('/api/config/branches'),
|
||||
fetch('/api/config/build'),
|
||||
fetch('/api/config/upload'),
|
||||
fetch('/api/config'),
|
||||
])
|
||||
apps.value = await appsRes.json()
|
||||
schemes.value = await schemesRes.json()
|
||||
servers.value = await serversRes.json()
|
||||
branches.value = await branchesRes.json()
|
||||
buildSettings.value = await buildRes.json()
|
||||
uploadConfig.value = await uploadRes.json()
|
||||
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
||||
}
|
||||
|
||||
// 服务器环境管理
|
||||
const openServerModal = (name = null, server = null) => {
|
||||
editingServerName.value = name
|
||||
serverForm.value = server ? { ...server, name } : { name: '', api: '', assDom: '', universalLink: '' }
|
||||
showServerModal.value = true
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
if (!serverForm.value.name || !serverForm.value.api) {
|
||||
alert('请填写必填字段:环境名称、API 地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingServerName.value) {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(editingServerName.value)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/config/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverForm.value),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '创建失败')
|
||||
}
|
||||
}
|
||||
showServerModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteServer = async (name) => {
|
||||
if (!confirm(`确定删除环境「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '删除失败')
|
||||
}
|
||||
await loadData()
|
||||
alert('删除成功')
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// App 相关
|
||||
const openAppModal = (id = null, app = null) => {
|
||||
editingAppId.value = id
|
||||
if (app) {
|
||||
appForm.value = JSON.parse(JSON.stringify(app))
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
} else {
|
||||
appForm.value = {
|
||||
name: '',
|
||||
server: '',
|
||||
AppGuid: '',
|
||||
API: '',
|
||||
AssDom: '',
|
||||
UniversalLink: '',
|
||||
weixinlogin: '',
|
||||
weixinpay: '',
|
||||
tencent: '',
|
||||
AlivcLicenseKey: '',
|
||||
certificates: {},
|
||||
}
|
||||
}
|
||||
showAppModal.value = true
|
||||
}
|
||||
|
||||
const onServerChange = () => {
|
||||
const serverName = appForm.value.server
|
||||
if (serverName && servers.value[serverName]) {
|
||||
const server = servers.value[serverName]
|
||||
appForm.value.API = server.api || ''
|
||||
appForm.value.AssDom = server.assDom || ''
|
||||
appForm.value.UniversalLink = server.universalLink || ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCert = (certType) => {
|
||||
if (!appForm.value.certificates) appForm.value.certificates = {}
|
||||
if (appForm.value.certificates[certType]) {
|
||||
delete appForm.value.certificates[certType]
|
||||
} else {
|
||||
appForm.value.certificates[certType] = {
|
||||
name: '',
|
||||
pro: '',
|
||||
cer: '',
|
||||
theme: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveApp = async () => {
|
||||
if (!appForm.value.name || !appForm.value.AppGuid) {
|
||||
alert('请填写必填字段:App 名称、AppGuid')
|
||||
return
|
||||
}
|
||||
|
||||
// 清理空证书
|
||||
if (appForm.value.certificates) {
|
||||
Object.keys(appForm.value.certificates).forEach(key => {
|
||||
if (!appForm.value.certificates[key].name) {
|
||||
delete appForm.value.certificates[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理空值
|
||||
const cleanData = { ...appForm.value }
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === '' || cleanData[key] === null || cleanData[key] === undefined) {
|
||||
delete cleanData[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
let res
|
||||
if (editingAppId.value) {
|
||||
res = await fetch(`/api/config/apps/${editingAppId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
} else {
|
||||
res = await fetch('/api/config/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cleanData),
|
||||
})
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '保存失败' }))
|
||||
throw new Error(err.detail || '保存失败')
|
||||
}
|
||||
showAppModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApp = async (id) => {
|
||||
if (!confirm('确定删除此 App?')) return
|
||||
await fetch(`/api/config/apps/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// Scheme 相关
|
||||
const openSchemeModal = (id = null, scheme = null) => {
|
||||
editingSchemeId.value = id
|
||||
schemeForm.value = scheme ? { ...scheme } : { name: '', ossFloder: '' }
|
||||
showSchemeModal.value = true
|
||||
}
|
||||
|
||||
const saveScheme = async () => {
|
||||
if (!schemeForm.value.name || !schemeForm.value.ossFloder) {
|
||||
alert('请填写必填字段:名称、OSS 目录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingSchemeId.value) {
|
||||
const res = await fetch(`/api/config/schemes/${editingSchemeId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存失败')
|
||||
} else {
|
||||
const res = await fetch('/api/config/schemes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schemeForm.value),
|
||||
})
|
||||
if (!res.ok) throw new Error('创建失败')
|
||||
}
|
||||
showSchemeModal.value = false
|
||||
await loadData()
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteScheme = async (id) => {
|
||||
if (!confirm('确定删除此 Scheme?')) return
|
||||
await fetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// 分支管理
|
||||
const addBranch = async () => {
|
||||
const name = newBranch.value.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
const res = await fetch('/api/config/branches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || '添加失败')
|
||||
}
|
||||
newBranch.value = ''
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('添加失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBranch = async (name) => {
|
||||
if (!confirm(`确定删除分支「${name}」?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('删除失败')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他
|
||||
const saveBuildSettings = async () => {
|
||||
await fetch('/api/config/build', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildSettings.value),
|
||||
})
|
||||
alert('设置已保存')
|
||||
}
|
||||
|
||||
const saveUploadConfig = async () => {
|
||||
try {
|
||||
await fetch('/api/config/upload', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(uploadConfig.value),
|
||||
})
|
||||
alert('上传配置已保存')
|
||||
} catch (e) {
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveJson = async () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
await loadData()
|
||||
alert('配置已保存')
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const config = JSON.parse(jsonContent.value)
|
||||
jsonContent.value = JSON.stringify(config, null, 2)
|
||||
} catch (e) {
|
||||
alert('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.access-denied { text-align: center; padding: 60px 20px; background: white; border-radius: 12px; }
|
||||
.access-denied h3 { font-size: 18px; color: #666; margin-bottom: 8px; }
|
||||
.access-denied p { color: #999; margin-bottom: 20px; }
|
||||
.btn-login { background: #1890ff; color: white; border: none; padding: 10px 32px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.admin-page { display: grid; grid-template-columns: 200px 1fr; gap: 24px; min-height: calc(100vh - 120px); }
|
||||
.sidebar { background: white; border-radius: 12px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.sidebar h3 { font-size: 13px; color: #999; margin-bottom: 12px; text-transform: uppercase; }
|
||||
.sidebar-menu { list-style: none; }
|
||||
.sidebar-menu li { padding: 10px 12px; border-radius: 6px; cursor: pointer; color: #333; margin-bottom: 4px; }
|
||||
.sidebar-menu li:hover { background: #f5f5f5; }
|
||||
.sidebar-menu li.active { background: #e6f7ff; color: #1890ff; }
|
||||
.main-content { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.main-content h2 { font-size: 18px; margin-bottom: 20px; color: #1a1a2e; }
|
||||
.config-table { width: 100%; border-collapse: collapse; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
.action-btns { display: flex; gap: 8px; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.action-btn.delete:hover { border-color: #ff4d4f; color: #ff4d4f; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; font-weight: 500; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; }
|
||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #1890ff; }
|
||||
.form-group input:disabled { background: #f5f5f5; color: #999; cursor: not-allowed; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; width: 100%; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal-content { background: white; border-radius: 12px; padding: 24px; width: 500px; max-height: 80vh; overflow-y: auto; }
|
||||
.modal-large { width: 700px; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
||||
.section-title { font-size: 14px; color: #1890ff; margin: 20px 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
.cert-section { margin-bottom: 16px; padding: 12px; background: #fafafa; border-radius: 8px; }
|
||||
.cert-header { margin-bottom: 12px; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 500; }
|
||||
.checkbox-label input { width: 16px; height: 16px; }
|
||||
.cert-fields { padding-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="history-page">
|
||||
<div class="header-row">
|
||||
<h2>打包历史</h2>
|
||||
<div class="filters">
|
||||
<select v-model="filterBuildType" class="filter-select">
|
||||
<option value="">全部类型</option>
|
||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
||||
<option value="App_Store">App_Store</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" class="filter-select">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="running">打包中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>App</th>
|
||||
<th>打包类型</th>
|
||||
<th>Scheme</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.id">
|
||||
<td>{{ formatTime(task.created_at) }}</td>
|
||||
<td>{{ task.app_name }}</td>
|
||||
<td>
|
||||
<span :class="['build-type-badge', task.build_type === 'App_Store' ? 'badge-appstore' : 'badge-adhoc']">
|
||||
{{ task.build_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ task.scheme_name }}</td>
|
||||
<td>
|
||||
<span :class="['task-status', `status-${task.status}`]">{{ statusText(task.status) }}</span>
|
||||
</td>
|
||||
<td class="action-btns">
|
||||
<button class="action-btn" @click="viewLogs(task.id)">日志</button>
|
||||
<button v-if="task.dsym_path" class="action-btn" @click="downloadDsym(task.id)">dSYM</button>
|
||||
<button v-if="task.obfuscation_maps_path" class="action-btn" @click="downloadObfMaps(task.id)">混淆映射</button>
|
||||
<button v-if="task.oss_url" class="action-btn" @click="downloadIpa(task.id)">下载</button>
|
||||
<button v-if="task.qr_code_path" class="action-btn" @click="showQrCode(task)">二维码</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredTasks.length">
|
||||
<td colspan="6" style="text-align: center; color: #999; padding: 40px;">暂无打包记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<div v-if="showQrModal" class="modal-overlay" @click.self="showQrModal = false">
|
||||
<div class="qr-modal">
|
||||
<div class="modal-header">
|
||||
<h3>下载二维码 - {{ qrTask?.app_name }}</h3>
|
||||
<button class="modal-close" @click="showQrModal = false">×</button>
|
||||
</div>
|
||||
<div class="qr-content">
|
||||
<img v-if="qrTask" :src="`/api/tasks/${qrTask.id}/qrcode`" alt="下载二维码" class="qr-image">
|
||||
<div class="qr-info">
|
||||
<p><strong>App:</strong> {{ qrTask?.app_name }}</p>
|
||||
<p><strong>版本:</strong> {{ qrTask?.scheme_name }}</p>
|
||||
<p><strong>类型:</strong> {{ qrTask?.build_type }}</p>
|
||||
<p><strong>时间:</strong> {{ formatTime(qrTask?.created_at) }}</p>
|
||||
</div>
|
||||
<div class="qr-actions">
|
||||
<button class="btn btn-primary" @click="downloadQrCode">保存二维码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const tasks = ref([])
|
||||
const filterBuildType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const showQrModal = ref(false)
|
||||
const qrTask = ref(null)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
// 默认隐藏失败和已取消的任务
|
||||
if (!filterStatus.value && (task.status === 'failed' || task.status === 'cancelled')) return false
|
||||
if (filterBuildType.value && task.build_type !== filterBuildType.value) return false
|
||||
if (filterStatus.value && task.status !== filterStatus.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/tasks?limit=100')
|
||||
tasks.value = await res.json()
|
||||
})
|
||||
|
||||
const viewLogs = (taskId) => {
|
||||
router.push({ path: '/build', query: { taskId } })
|
||||
}
|
||||
|
||||
const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
}
|
||||
|
||||
const downloadObfMaps = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
}
|
||||
|
||||
const downloadIpa = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/ipa`)
|
||||
}
|
||||
|
||||
const showQrCode = (task) => {
|
||||
qrTask.value = task
|
||||
showQrModal.value = true
|
||||
}
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (qrTask.value) {
|
||||
const link = document.createElement('a')
|
||||
link.href = `/api/tasks/${qrTask.value.id}/qrcode`
|
||||
link.download = `${qrTask.value.app_name}_二维码.png`
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
const statusText = (s) => {
|
||||
const map = { pending: '等待中', running: '打包中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.history-page { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-row h2 { font-size: 18px; color: #1a1a2e; margin: 0; }
|
||||
.filters { display: flex; gap: 12px; }
|
||||
.filter-select { padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 14px; min-width: 120px; }
|
||||
.filter-select:focus { outline: none; border-color: #1890ff; }
|
||||
|
||||
.config-table { width: 100%; border-collapse: collapse; }
|
||||
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
|
||||
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||
.config-table td { font-size: 14px; }
|
||||
.config-table tr:hover { background: #fafafa; }
|
||||
|
||||
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.badge-adhoc { background: #e6f7ff; color: #1890ff; }
|
||||
.badge-appstore { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.action-btn { padding: 4px 12px; border: 1px solid #d9d9d9; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
||||
.action-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||
|
||||
.task-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.status-pending { background: #f0f0f0; color: #666; }
|
||||
.status-running { background: #e6f7ff; color: #1890ff; }
|
||||
.status-completed { background: #f6ffed; color: #52c41a; }
|
||||
.status-failed { background: #fff2f0; color: #ff4d4f; }
|
||||
.status-cancelled { background: #f0f0f0; color: #999; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.qr-modal { background: white; border-radius: 12px; padding: 24px; width: 400px; }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.modal-header h3 { font-size: 16px; margin: 0; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #999; }
|
||||
.qr-content { text-align: center; }
|
||||
.qr-image { width: 200px; height: 200px; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 16px; }
|
||||
.qr-info { text-align: left; padding: 16px; background: #fafafa; border-radius: 8px; margin-bottom: 16px; }
|
||||
.qr-info p { margin: 8px 0; font-size: 14px; color: #333; }
|
||||
.qr-actions { display: flex; justify-content: center; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user