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:
yuzhiran
2026-06-23 13:30:19 +08:00
parent 897f72012f
commit 302efd5f2d
+105 -34
View File
@@ -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,7 +174,8 @@
关键词详情:{{ 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%">
<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">
@@ -154,6 +190,18 @@
</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();
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';
window.installUniNav(this.$parent ? null : app).component('uni-nav', window.UniNav);
} catch (e) {
window.location.href = '/login.html';
}
}.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>