fix(ui): responsive mobile layout with auth flow refactoring for campaigns
Adds: v-cloak fix, mobile card-list views for all tables, responsive CSS breakpoints, early auth redirect, CDN script reorder to head, ElementPlusIconsVue safety check, global ElMessage reference. Moves async mounted to .then() pattern to avoid Vue 3 SFC issues. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
[v-cloak] { display: none; }
|
||||
.data-table { display: block; }
|
||||
@media (max-width: 768px) { .data-table { display: none; } }
|
||||
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
@@ -14,8 +15,18 @@
|
||||
.stat-card .num { font-size: 28px; font-weight: 700; color: #2563eb; }
|
||||
.stat-card .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.search-engine-tag { margin: 2px; }
|
||||
@media (max-width: 768px) {
|
||||
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px; }
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.filter-bar .el-button { margin-left: 0 !important; }
|
||||
.stat-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.toolbar { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
@@ -89,6 +100,17 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="p in products" :key="p.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ p.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ typeLabel(p.type) }}</span></div>
|
||||
<div class="card-row"><span class="card-label">URL</span><span class="card-value"><a :href="p.url" target="_blank" style="color:#409eff;word-break:break-all;">{{ p.url || '-' }}</a></span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="editProduct(p)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteProduct(p)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="products.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="products.length" :page-size="pageSize" :current-page="productPage" @current-change="productPage = $event"></el-pagination>
|
||||
</div>
|
||||
@@ -129,6 +151,19 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="c in campaigns" :key="c.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">活动</span><span class="card-value">{{ c.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ c.product_name || '-' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="c.status === 'active' ? 'success' : c.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[c.status] || c.status }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">引擎</span><span class="card-value"><el-tag v-for="e in (c.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag></span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="viewCampaignKeywords(c)">关键词</el-button>
|
||||
<el-button size="small" @click="editCampaign(c)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCampaign(c)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="campaigns.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="campaigns.length" :page-size="pageSize" :current-page="campaignPage" @current-change="campaignPage = $event"></el-pagination>
|
||||
</div>
|
||||
@@ -139,21 +174,34 @@
|
||||
关键词详情:{{ selectedCampaign.name }}
|
||||
<el-button size="small" type="primary" style="margin-left:8px;" @click="showKeywordDialog = true">添加关键词</el-button>
|
||||
</h4>
|
||||
<el-table v-if="campaignKeywords.length" :data="campaignKeywords" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="keyword" label="关键词" min-width="150"></el-table-column>
|
||||
<el-table-column prop="search_volume" label="搜索量" width="80"></el-table-column>
|
||||
<el-table-column label="难度" width="70">
|
||||
<template #default="s"><el-tag :type="(s.row.difficulty || 0) > 0.7 ? 'danger' : (s.row.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ s.row.difficulty != null ? (s.row.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_rank" label="当前排名" width="80"></el-table-column>
|
||||
<el-table-column prop="target_rank" label="目标排名" width="80"></el-table-column>
|
||||
<el-table-column prop="best_rank" label="最佳排名" width="80"></el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="s">
|
||||
<el-button size="small" type="danger" @click="deleteKeyword(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template v-if="campaignKeywords.length">
|
||||
<el-table :data="campaignKeywords" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="keyword" label="关键词" min-width="150"></el-table-column>
|
||||
<el-table-column prop="search_volume" label="搜索量" width="80"></el-table-column>
|
||||
<el-table-column label="难度" width="70">
|
||||
<template #default="s"><el-tag :type="(s.row.difficulty || 0) > 0.7 ? 'danger' : (s.row.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ s.row.difficulty != null ? (s.row.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_rank" label="当前排名" width="80"></el-table-column>
|
||||
<el-table-column prop="target_rank" label="目标排名" width="80"></el-table-column>
|
||||
<el-table-column prop="best_rank" label="最佳排名" width="80"></el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="s">
|
||||
<el-button size="small" type="danger" @click="deleteKeyword(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="kw in campaignKeywords" :key="kw.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ kw.keyword }}</span></div>
|
||||
<div class="card-row"><span class="card-label">搜索量</span><span class="card-value">{{ kw.search_volume || 0 }}</span></div>
|
||||
<div class="card-row"><span class="card-label">难度</span><span class="card-value"><el-tag :type="(kw.difficulty || 0) > 0.7 ? 'danger' : (kw.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ kw.difficulty != null ? (kw.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">排名</span><span class="card-value">当前 {{ kw.current_rank || '-' }} 目标 {{ kw.target_rank || '-' }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" type="danger" @click="deleteKeyword(kw)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="text-align:center;padding:20px;color:#909399;font-size:13px;">暂无关键词数据</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +225,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="排名" width="80">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.rank != null && s.row.rank <= 10 ? 'success' : s.row.rank != null && s.row.rank <= 30 ? 'warning' : 'info'" size="small">{{ s.row.rank || '-' }}</el-tag>
|
||||
<el-tag :type="s.row.rank == null ? 'info' : s.row.rank <= 10 ? 'success' : s.row.rank <= 30 ? 'warning' : 'info'" size="small">{{ s.row.rank || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url_found" label="排名URL" min-width="200">
|
||||
@@ -187,6 +235,14 @@
|
||||
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="r in keywordRankings" :key="r.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">关键词</span><span class="card-value">{{ r.keyword }}</span></div>
|
||||
<div class="card-row"><span class="card-label">引擎</span><span class="card-value">{{ engineLabel(r.search_engine) }}</span></div>
|
||||
<div class="card-row"><span class="card-label">排名</span><span class="card-value"><el-tag :type="r.rank == null ? 'info' : r.rank <= 10 ? 'success' : r.rank <= 30 ? 'warning' : 'info'" size="small">{{ r.rank || '-' }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ r.checked_at ? new Date(r.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="keywordRankings.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="keywordRankings.length" :page-size="pageSize" :current-page="rankingPage" @current-change="rankingPage = $event"></el-pagination>
|
||||
</div>
|
||||
@@ -223,6 +279,15 @@
|
||||
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="a in audits" :key="a.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">产品</span><span class="card-value">{{ a.product_name || '-' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">总分</span><span class="card-value"><el-tag :type="(a.overall_score || 0) >= 80 ? 'success' : (a.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ a.overall_score != null ? a.overall_score.toFixed(1) : '-' }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">评分</span><span class="card-value" style="font-size:12px;">M{{ a.meta_score || '-' }} H{{ a.heading_score || '-' }} C{{ a.content_score || '-' }} P{{ a.perf_score || '-' }} L{{ a.links_score || '-' }} Mob{{ a.mobile_score || '-' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">问题数</span><span class="card-value">{{ a.issues_found || 0 }}</span></div>
|
||||
<div class="card-row"><span class="card-label">时间</span><span class="card-value">{{ a.checked_at ? new Date(a.checked_at).toLocaleString('zh-CN') : '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -260,6 +325,19 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="t in optimizationTasks" :key="t.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">严重度</span><span class="card-value"><el-tag :type="t.severity === 'high' ? 'danger' : t.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[t.severity] || t.severity }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">分类</span><span class="card-value">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[t.category] || t.category }}</span></div>
|
||||
<div class="card-row"><span class="card-label">问题</span><span class="card-value" style="font-size:12px;">{{ t.issue }}</span></div>
|
||||
<div class="card-row"><span class="card-label">建议</span><span class="card-value" style="font-size:12px;">{{ t.recommendation }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="t.status === 'resolved' ? 'success' : t.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[t.status] || t.status }}</el-tag></span></div>
|
||||
<div class="card-actions">
|
||||
<el-button v-if="t.status === 'open'" size="small" type="success" @click="updateTaskStatus(t, 'resolved')">解决</el-button>
|
||||
<el-button size="small" @click="updateTaskStatus(t, 'ignored')">忽略</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,11 +420,13 @@
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted, nextTick } = Vue;
|
||||
const { ElMessage } = ElementPlus;
|
||||
|
||||
// Auth check — redirect early if no token
|
||||
const authToken = localStorage.getItem('authToken') || '';
|
||||
if (!authToken) { window.location.href = '/login.html'; }
|
||||
|
||||
const app = createApp({
|
||||
data() {
|
||||
@@ -380,7 +460,7 @@
|
||||
showKeywordDialog: false,
|
||||
keywordForm: { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 },
|
||||
|
||||
token: '',
|
||||
token: authToken,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -435,7 +515,7 @@
|
||||
await this.loadTasks();
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error(e.message);
|
||||
ElMessage.error(e.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -464,7 +544,7 @@
|
||||
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.productForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '操作失败');
|
||||
ElementPlus.ElMessage.success(this.editingProduct ? '已更新' : '已创建');
|
||||
ElMessage.success(this.editingProduct ? '已更新' : '已创建');
|
||||
this.showProductDialog = false;
|
||||
this.editingProduct = null;
|
||||
this.productForm = { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' };
|
||||
@@ -473,7 +553,7 @@
|
||||
async deleteProduct(p) {
|
||||
if (!confirm('确定删除产品「' + p.name + '」?')) return;
|
||||
await this.fetchJson(this.api('/products/' + p.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
ElMessage.success('已删除');
|
||||
this.products = await this.fetchJson(this.api('/products'));
|
||||
},
|
||||
|
||||
@@ -502,7 +582,7 @@
|
||||
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.campaignForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '操作失败');
|
||||
ElementPlus.ElMessage.success(this.editingCampaign ? '已更新' : '已创建');
|
||||
ElMessage.success(this.editingCampaign ? '已更新' : '已创建');
|
||||
this.showCampaignDialog = false;
|
||||
this.editingCampaign = null;
|
||||
this.campaignForm = { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' };
|
||||
@@ -511,18 +591,18 @@
|
||||
async deleteCampaign(c) {
|
||||
if (!confirm('确定删除活动「' + c.name + '」?')) return;
|
||||
await this.fetchJson(this.api('/campaigns/' + c.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
ElMessage.success('已删除');
|
||||
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
||||
},
|
||||
|
||||
// Keywords
|
||||
async saveKeyword() {
|
||||
if (!this.selectedCampaign) { ElementPlus.ElMessage.warning('请先选择活动'); return; }
|
||||
if (!this.selectedCampaign) { ElMessage.warning('请先选择活动'); return; }
|
||||
this.keywordForm.campaign_id = this.selectedCampaign.id;
|
||||
const r = await fetch(this.api('/keywords'), { method: 'POST', headers: this.headers(), body: JSON.stringify(this.keywordForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '添加失败');
|
||||
ElementPlus.ElMessage.success('已添加');
|
||||
ElMessage.success('已添加');
|
||||
this.showKeywordDialog = false;
|
||||
this.keywordForm = { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 };
|
||||
this.loadCampaignKeywords(this.selectedCampaign.id);
|
||||
@@ -530,14 +610,14 @@
|
||||
async deleteKeyword(kw) {
|
||||
if (!confirm('确定删除关键词「' + kw.keyword + '」?')) return;
|
||||
await this.fetchJson(this.api('/keywords/' + kw.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
ElMessage.success('已删除');
|
||||
if (this.selectedCampaign) this.loadCampaignKeywords(this.selectedCampaign.id);
|
||||
},
|
||||
|
||||
// Optimization Tasks
|
||||
async updateTaskStatus(task, status) {
|
||||
await this.fetchJson(this.api('/optimization-tasks/' + task.id), { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ status: status }) });
|
||||
ElementPlus.ElMessage.success('已更新');
|
||||
ElMessage.success('已更新');
|
||||
this.loadTasks();
|
||||
},
|
||||
|
||||
@@ -549,32 +629,23 @@
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.token = localStorage.getItem('authToken') || '';
|
||||
if (!this.token) { window.location.href = '/login.html'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + this.token } });
|
||||
if (!r.ok) throw new Error('unauthorized');
|
||||
const d = await r.json();
|
||||
this.currentUser = d.user || d;
|
||||
this.isAdmin = this.currentUser.role === 'admin';
|
||||
window.installUniNav(this.$parent ? null : app).component('uni-nav', window.UniNav);
|
||||
} catch (e) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
mounted() {
|
||||
const token = this.token;
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('unauthorized'); return r.json(); })
|
||||
.then(function (d) {
|
||||
this.currentUser = d.user || d;
|
||||
this.isAdmin = this.currentUser.role === 'admin';
|
||||
}.bind(this))
|
||||
.catch(function () { window.location.href = '/login.html'; });
|
||||
this.loadTabData();
|
||||
},
|
||||
});
|
||||
|
||||
// Register Element Plus icons
|
||||
const { Edit, Delete, Plus, Search, Setting, Monitor, Document, DataBoard, Folder, Menu as IconMenu } = ElementPlusIconsVue || {};
|
||||
if (ElementPlusIconsVue) {
|
||||
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, comp);
|
||||
}
|
||||
}
|
||||
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user