feat(ops-web): add operations console frontend

Vue 3 + Ant Design Vue SPA scaffold for the internal ops console. Ships
the auth, account list, and audit log views that consume the new ops-api
endpoints, plus the nginx/vite/typecheck plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:33:17 +08:00
parent f63e800f21
commit f5254f6a27
20 changed files with 1830 additions and 7 deletions
+308
View File
@@ -0,0 +1,308 @@
<template>
<h2 class="ops-page-title">账号管理</h2>
<div class="ops-card">
<div class="ops-toolbar">
<a-input
v-model:value="filter.keyword"
placeholder="搜索账号 / 姓名 / 邮箱"
style="width: 240px"
allow-clear
@press-enter="reload"
@change="onKeywordChange"
/>
<a-select
v-model:value="filter.status"
style="width: 140px"
:options="statusOptions"
placeholder="状态"
allow-clear
@change="reload"
/>
<a-button @click="reload">刷新</a-button>
<span style="flex: 1" />
<a-button type="primary" @click="openCreate">新增账号</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === "active" ? "启用" : "停用" }}
</a-tag>
</template>
<template v-else-if="column.key === 'last_login_at'">
{{ record.last_login_at ? formatDate(record.last_login_at) : "" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a @click="openResetPassword(record)">重置密码</a>
<a-popconfirm
:title="record.status === 'active' ? '确认停用该账号?' : '确认启用该账号?'"
:disabled="record.id === auth.operator?.id"
@confirm="toggleStatus(record)"
>
<a :class="{ disabled: record.id === auth.operator?.id }">
{{ record.status === "active" ? "停用" : "启用" }}
</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="createOpen"
title="新增账号"
:confirm-loading="createLoading"
@ok="submitCreate"
@cancel="resetCreate"
>
<a-form layout="vertical" :model="createForm">
<a-form-item label="账号" required>
<a-input v-model:value="createForm.username" placeholder="登录账号" />
</a-form-item>
<a-form-item label="姓名" required>
<a-input v-model:value="createForm.display_name" placeholder="显示名称" />
</a-form-item>
<a-form-item label="邮箱">
<a-input v-model:value="createForm.email" placeholder="可选" />
</a-form-item>
<a-form-item label="初始密码" required help="至少 8 位">
<a-input-password v-model:value="createForm.password" placeholder="≥ 8 位" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="resetOpen"
:title="resetTarget ? `重置 ${resetTarget.display_name} 的密码` : '重置密码'"
:confirm-loading="resetLoading"
@ok="submitResetPassword"
@cancel="resetOpen = false"
>
<a-form layout="vertical">
<a-form-item label="新密码" required help="至少 8 位">
<a-input-password v-model:value="resetPasswordValue" placeholder="≥ 8 位" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import type { TablePaginationConfig } from "ant-design-vue";
import { message } from "ant-design-vue";
import dayjs from "dayjs";
import { computed, onMounted, reactive, ref } from "vue";
import { OpsApiError, http } from "@/lib/http";
import { useAuthStore } from "@/stores/auth";
interface AccountRow {
id: number;
username: string;
display_name: string;
email: string | null;
role: string;
status: string;
last_login_at: string | null;
created_at: string;
updated_at: string;
}
interface ListResult {
items: AccountRow[];
total: number;
page: number;
size: number;
}
const auth = useAuthStore();
const filter = reactive({
keyword: "",
status: undefined as string | undefined,
});
const statusOptions = [
{ label: "启用", value: "active" },
{ label: "停用", value: "disabled" },
];
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "账号", dataIndex: "username", key: "username" },
{ title: "姓名", dataIndex: "display_name", key: "display_name" },
{ title: "邮箱", dataIndex: "email", key: "email" },
{ title: "状态", key: "status", width: 100 },
{ title: "最近登录", key: "last_login_at", width: 180 },
{ title: "创建时间", key: "created_at", width: 180 },
{ title: "操作", key: "actions", width: 200 },
];
const rows = ref<AccountRow[]>([]);
const loading = ref(false);
const page = ref(1);
const size = ref(20);
const total = ref(0);
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: size.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
}));
let keywordTimer: number | null = null;
function onKeywordChange() {
if (keywordTimer) window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(() => {
page.value = 1;
void reload();
}, 300);
}
async function reload() {
loading.value = true;
try {
const result = await http.get<ListResult>("/accounts", {
keyword: filter.keyword || undefined,
status: filter.status || undefined,
page: page.value,
size: size.value,
});
rows.value = result.items;
total.value = result.total;
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
loading.value = false;
}
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1;
size.value = pag.pageSize ?? 20;
void reload();
}
const createOpen = ref(false);
const createLoading = ref(false);
const createForm = reactive({
username: "",
display_name: "",
email: "",
password: "",
});
function resetCreate() {
createForm.username = "";
createForm.display_name = "";
createForm.email = "";
createForm.password = "";
createOpen.value = false;
}
function openCreate() {
resetCreate();
createOpen.value = true;
}
async function submitCreate() {
if (!createForm.username.trim()) {
message.warning("请输入账号");
return;
}
if (createForm.password.length < 8) {
message.warning("初始密码至少 8 位");
return;
}
createLoading.value = true;
try {
await http.post("/accounts", {
username: createForm.username.trim(),
display_name: createForm.display_name.trim() || createForm.username.trim(),
email: createForm.email.trim(),
password: createForm.password,
});
message.success("新建成功");
resetCreate();
void reload();
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
createLoading.value = false;
}
}
const resetOpen = ref(false);
const resetLoading = ref(false);
const resetTarget = ref<AccountRow | null>(null);
const resetPasswordValue = ref("");
function openResetPassword(row: AccountRow) {
resetTarget.value = row;
resetPasswordValue.value = "";
resetOpen.value = true;
}
async function submitResetPassword() {
if (!resetTarget.value) return;
if (resetPasswordValue.value.length < 8) {
message.warning("新密码至少 8 位");
return;
}
resetLoading.value = true;
try {
await http.post(`/accounts/${resetTarget.value.id}/password`, {
password: resetPasswordValue.value,
});
message.success("重置成功");
resetOpen.value = false;
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
resetLoading.value = false;
}
}
async function toggleStatus(row: AccountRow) {
const next = row.status === "active" ? "disabled" : "active";
try {
await http.post(`/accounts/${row.id}/status`, { status: next });
message.success(next === "active" ? "已启用" : "已停用");
void reload();
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
}
}
function formatDate(value: string | null | undefined): string {
return value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "—";
}
onMounted(() => {
void reload();
});
</script>
<style scoped>
.disabled {
color: #c4c4c4;
cursor: not-allowed;
pointer-events: none;
}
</style>