first commit
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
createEmployee,
|
||||
deleteEmployee,
|
||||
getEmployee,
|
||||
listEmployees,
|
||||
listRoles,
|
||||
listStores,
|
||||
updateEmployee,
|
||||
updateEmployeeStatus,
|
||||
type Employee,
|
||||
type EmployeePayload,
|
||||
type EmployeeStatus,
|
||||
type RoleOption,
|
||||
type StoreOption
|
||||
} from "@/api/access";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import Delete from "~icons/ep/delete";
|
||||
import CircleCheck from "~icons/ep/circle-check";
|
||||
import CircleClose from "~icons/ep/circle-close";
|
||||
|
||||
defineOptions({
|
||||
name: "EmployeeManagement"
|
||||
});
|
||||
|
||||
type EmployeeFormState = EmployeePayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const phonePattern = /^1[3-9]\d{9}$/;
|
||||
const tableLoading = ref(false);
|
||||
const catalogLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const employees = ref<Employee[]>([]);
|
||||
const stores = ref<StoreOption[]>([]);
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
storeId: undefined as number | undefined,
|
||||
status: undefined as EmployeeStatus | undefined,
|
||||
keyword: "",
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
const form = reactive<EmployeeFormState>({
|
||||
storeId: undefined as unknown as number,
|
||||
name: "",
|
||||
phone: "",
|
||||
status: "ACTIVE",
|
||||
remark: "",
|
||||
roleIds: []
|
||||
});
|
||||
|
||||
const rules: FormRules<EmployeeFormState> = {
|
||||
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
|
||||
name: [
|
||||
{ required: true, message: "请输入员工姓名", trigger: "blur" },
|
||||
{ max: 50, message: "员工姓名不能超过 50 个字符", trigger: "blur" }
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: "请输入手机号", trigger: "blur" },
|
||||
{
|
||||
pattern: phonePattern,
|
||||
message: "请输入正确的中国大陆手机号",
|
||||
trigger: "blur"
|
||||
}
|
||||
],
|
||||
status: [{ required: true, message: "请选择员工状态", trigger: "change" }],
|
||||
roleIds: [{ type: "array", message: "请选择角色", trigger: "change" }],
|
||||
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
|
||||
};
|
||||
|
||||
const activeCount = computed(
|
||||
() => employees.value.filter(item => item.status === "ACTIVE").length
|
||||
);
|
||||
const inactiveCount = computed(
|
||||
() => employees.value.filter(item => item.status === "INACTIVE").length
|
||||
);
|
||||
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
storeId: stores.value[0]?.id,
|
||||
name: "",
|
||||
phone: "",
|
||||
status: "ACTIVE",
|
||||
remark: "",
|
||||
roleIds: []
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): EmployeePayload {
|
||||
return {
|
||||
storeId: form.storeId,
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
status: form.status,
|
||||
remark: form.remark?.trim() ? form.remark.trim() : null,
|
||||
roleIds: form.roleIds
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCatalog() {
|
||||
catalogLoading.value = true;
|
||||
try {
|
||||
const [storeResult, roleResult] = await Promise.all([
|
||||
listStores(),
|
||||
listRoles()
|
||||
]);
|
||||
stores.value = storeResult.data;
|
||||
roles.value = roleResult.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败"));
|
||||
} finally {
|
||||
catalogLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEmployees() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listEmployees({
|
||||
storeId: query.storeId,
|
||||
status: query.status,
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize
|
||||
});
|
||||
employees.value = result.data.items;
|
||||
pagination.total = result.data.pagination.total;
|
||||
pagination.totalPages = result.data.pagination.totalPages;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载员工列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1;
|
||||
fetchEmployees();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.storeId = undefined;
|
||||
query.status = undefined;
|
||||
query.keyword = "";
|
||||
query.page = 1;
|
||||
query.pageSize = 20;
|
||||
fetchEmployees();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.page = page;
|
||||
fetchEmployees();
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
query.page = 1;
|
||||
query.pageSize = pageSize;
|
||||
fetchEmployees();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function openEditDialog(row: Employee) {
|
||||
try {
|
||||
const result = await getEmployee(row.id);
|
||||
const employee = result.data;
|
||||
Object.assign(form, {
|
||||
id: employee.id,
|
||||
storeId: employee.storeId,
|
||||
name: employee.name,
|
||||
phone: employee.phone,
|
||||
status: employee.status,
|
||||
remark: employee.remark ?? "",
|
||||
roleIds: employee.roles.map(role => role.id)
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
formRef.value?.clearValidate();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate();
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (form.id) {
|
||||
await updateEmployee(form.id, payload);
|
||||
ElMessage.success("员工信息已更新");
|
||||
} else {
|
||||
await createEmployee(payload);
|
||||
ElMessage.success("员工已创建");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchEmployees();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存员工失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: Employee) {
|
||||
const nextStatus: EmployeeStatus =
|
||||
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认${action}员工「${row.name}」?`,
|
||||
"状态变更",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: action,
|
||||
cancelButtonText: "取消"
|
||||
}
|
||||
);
|
||||
await updateEmployeeStatus(row.id, nextStatus);
|
||||
ElMessage.success(`已${action}`);
|
||||
fetchEmployees();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "状态变更失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmployee(row: Employee) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
|
||||
"删除员工",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "删除",
|
||||
cancelButtonText: "取消"
|
||||
}
|
||||
);
|
||||
await deleteEmployee(row.id);
|
||||
ElMessage.success("员工已删除");
|
||||
|
||||
if (employees.value.length === 1 && query.page > 1) {
|
||||
query.page -= 1;
|
||||
}
|
||||
fetchEmployees();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "删除员工失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCatalog();
|
||||
await fetchEmployees();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="employee-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">门店员工权限管理</p>
|
||||
<h1>员工管理</h1>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增员工
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="summary-strip">
|
||||
<div class="summary-item">
|
||||
<span>总员工</span>
|
||||
<strong>{{ pagination.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前页启用</span>
|
||||
<strong>{{ activeCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前页停用</span>
|
||||
<strong>{{ inactiveCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>角色数量</span>
|
||||
<strong>{{ roles.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.storeId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部门店"
|
||||
class="toolbar-control"
|
||||
:loading="catalogLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
class="toolbar-control"
|
||||
>
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
placeholder="搜索姓名或手机号"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="employees"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="employee-table"
|
||||
>
|
||||
<el-table-column prop="name" label="员工" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="employee-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.phone }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storeName" label="门店" min-width="150" />
|
||||
<el-table-column label="角色" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="role-tags">
|
||||
<el-tag
|
||||
v-for="role in row.roles"
|
||||
:key="role.id"
|
||||
effect="plain"
|
||||
size="small"
|
||||
>
|
||||
{{ role.name }}
|
||||
</el-tag>
|
||||
<span v-if="row.roles.length === 0" class="muted">未绑定</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.remark || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeEmployee(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-row">
|
||||
<span
|
||||
>共 {{ pagination.total }} 条,{{ pagination.totalPages }} 页</span
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
background
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="employee-form"
|
||||
>
|
||||
<el-form-item label="所属门店" prop="storeId">
|
||||
<el-select
|
||||
v-model="form.storeId"
|
||||
filterable
|
||||
placeholder="请选择门店"
|
||||
class="full-width"
|
||||
:loading="catalogLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="store.name"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="员工姓名" prop="name">
|
||||
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="11"
|
||||
placeholder="请输入 11 位手机号"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button label="ACTIVE">启用</el-radio-button>
|
||||
<el-radio-button label="INACTIVE">停用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roleIds">
|
||||
<el-select
|
||||
v-model="form.roleIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="请选择角色"
|
||||
class="full-width"
|
||||
:loading="catalogLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:label="`${role.name}(${role.code})`"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
placeholder="可填写班次、权限说明或其他备注"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.employee-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.employee-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.employee-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.role-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.employee-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.employee-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.keyword-input,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import noAccess from "@/assets/status/403.svg?component";
|
||||
|
||||
defineOptions({
|
||||
name: "403"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
|
||||
>
|
||||
<noAccess />
|
||||
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
|
||||
<p
|
||||
v-motion
|
||||
class="font-medium text-4xl mb-4! dark:text-white"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 80
|
||||
}
|
||||
}"
|
||||
>
|
||||
403
|
||||
</p>
|
||||
<p
|
||||
v-motion
|
||||
class="text-xl mb-4! text-gray-500"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 120
|
||||
}
|
||||
}"
|
||||
>
|
||||
抱歉,你无权访问该页面
|
||||
</p>
|
||||
<el-button
|
||||
v-motion
|
||||
type="primary"
|
||||
class="block mx-auto md:inline-block md:mx-0"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 160
|
||||
}
|
||||
}"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-content {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import noExist from "@/assets/status/404.svg?component";
|
||||
|
||||
defineOptions({
|
||||
name: "404"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
|
||||
>
|
||||
<noExist />
|
||||
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
|
||||
<p
|
||||
v-motion
|
||||
class="font-medium text-4xl mb-4! dark:text-white"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 80
|
||||
}
|
||||
}"
|
||||
>
|
||||
404
|
||||
</p>
|
||||
<p
|
||||
v-motion
|
||||
class="text-xl mb-4! text-gray-500"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 120
|
||||
}
|
||||
}"
|
||||
>
|
||||
抱歉,你访问的页面不存在
|
||||
</p>
|
||||
<el-button
|
||||
v-motion
|
||||
type="primary"
|
||||
class="block mx-auto md:inline-block md:mx-0"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 160
|
||||
}
|
||||
}"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-content {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import noServer from "@/assets/status/500.svg?component";
|
||||
|
||||
defineOptions({
|
||||
name: "500"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
|
||||
>
|
||||
<noServer />
|
||||
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
|
||||
<p
|
||||
v-motion
|
||||
class="font-medium text-4xl mb-4! dark:text-white"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 80
|
||||
}
|
||||
}"
|
||||
>
|
||||
500
|
||||
</p>
|
||||
<p
|
||||
v-motion
|
||||
class="text-xl mb-4! text-gray-500"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 120
|
||||
}
|
||||
}"
|
||||
>
|
||||
抱歉,服务器出错了
|
||||
</p>
|
||||
<el-button
|
||||
v-motion
|
||||
type="primary"
|
||||
class="block mx-auto md:inline-block md:mx-0"
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}"
|
||||
:enter="{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 160
|
||||
}
|
||||
}"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-content {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import Motion from "./utils/motion";
|
||||
import { useRouter } from "vue-router";
|
||||
import { message } from "@/utils/message";
|
||||
import { loginRules } from "./utils/rule";
|
||||
import { ref, reactive, toRaw } from "vue";
|
||||
import { debounce } from "@pureadmin/utils";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import { useLayout } from "@/layout/hooks/useLayout";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
import { initRouter, getTopMenu } from "@/router/utils";
|
||||
import { bg, avatar, illustration } from "./utils/static";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||
|
||||
import dayIcon from "@/assets/svg/day.svg?component";
|
||||
import darkIcon from "@/assets/svg/dark.svg?component";
|
||||
import Lock from "~icons/ri/lock-fill";
|
||||
import User from "~icons/ri/user-3-fill";
|
||||
|
||||
defineOptions({
|
||||
name: "Login"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const disabled = ref(false);
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
const { initStorage } = useLayout();
|
||||
initStorage();
|
||||
|
||||
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
|
||||
dataThemeChange(overallStyle.value);
|
||||
const { title } = useNav();
|
||||
|
||||
const ruleForm = reactive({
|
||||
username: "admin",
|
||||
password: "admin123"
|
||||
});
|
||||
|
||||
const onLogin = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate(valid => {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
useUserStoreHook()
|
||||
.loginByUsername({
|
||||
username: ruleForm.username,
|
||||
password: ruleForm.password
|
||||
})
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
// 获取后端路由
|
||||
return initRouter().then(() => {
|
||||
disabled.value = true;
|
||||
router
|
||||
.push(getTopMenu(true).path)
|
||||
.then(() => {
|
||||
message("登录成功", { type: "success" });
|
||||
})
|
||||
.finally(() => (disabled.value = false));
|
||||
});
|
||||
} else {
|
||||
message("登录失败", { type: "error" });
|
||||
}
|
||||
})
|
||||
.finally(() => (loading.value = false));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const immediateDebounce: any = debounce(
|
||||
formRef => onLogin(formRef),
|
||||
1000,
|
||||
true
|
||||
);
|
||||
|
||||
useEventListener(document, "keydown", ({ code }) => {
|
||||
if (
|
||||
["Enter", "NumpadEnter"].includes(code) &&
|
||||
!disabled.value &&
|
||||
!loading.value
|
||||
)
|
||||
immediateDebounce(ruleFormRef.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="select-none">
|
||||
<img :src="bg" class="wave" />
|
||||
<div class="flex-c absolute right-5 top-3">
|
||||
<!-- 主题 -->
|
||||
<el-switch
|
||||
v-model="dataTheme"
|
||||
inline-prompt
|
||||
:active-icon="dayIcon"
|
||||
:inactive-icon="darkIcon"
|
||||
@change="dataThemeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="login-container">
|
||||
<div class="img">
|
||||
<component :is="toRaw(illustration)" />
|
||||
</div>
|
||||
<div class="login-box">
|
||||
<div class="login-form">
|
||||
<avatar class="avatar" />
|
||||
<Motion>
|
||||
<h2 class="outline-hidden">{{ title }}</h2>
|
||||
</Motion>
|
||||
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
:model="ruleForm"
|
||||
:rules="loginRules"
|
||||
size="large"
|
||||
>
|
||||
<Motion :delay="100">
|
||||
<el-form-item
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入账号',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
prop="username"
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.username"
|
||||
clearable
|
||||
placeholder="账号"
|
||||
:prefix-icon="useRenderIcon(User)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</Motion>
|
||||
|
||||
<Motion :delay="150">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="ruleForm.password"
|
||||
clearable
|
||||
show-password
|
||||
placeholder="密码"
|
||||
:prefix-icon="useRenderIcon(Lock)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</Motion>
|
||||
|
||||
<Motion :delay="250">
|
||||
<el-button
|
||||
class="w-full mt-4!"
|
||||
size="default"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
@click="onLogin(ruleFormRef)"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</Motion>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import url("@/style/login.css");
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-input-group__append, .el-input-group__prepend) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { h, defineComponent, withDirectives, resolveDirective } from "vue";
|
||||
|
||||
/** 封装@vueuse/motion动画库中的自定义指令v-motion */
|
||||
export default defineComponent({
|
||||
name: "Motion",
|
||||
props: {
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
},
|
||||
render() {
|
||||
const { delay } = this;
|
||||
const motion = resolveDirective("motion");
|
||||
return withDirectives(
|
||||
h(
|
||||
"div",
|
||||
{},
|
||||
{
|
||||
default: () => [this.$slots.default()]
|
||||
}
|
||||
),
|
||||
[
|
||||
[
|
||||
motion,
|
||||
{
|
||||
initial: { opacity: 0, y: 100 },
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { reactive } from "vue";
|
||||
import type { FormRules } from "element-plus";
|
||||
|
||||
/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */
|
||||
export const REGEXP_PWD =
|
||||
/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/;
|
||||
|
||||
/** 登录校验 */
|
||||
const loginRules = reactive<FormRules>({
|
||||
password: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value === "") {
|
||||
callback(new Error("请输入密码"));
|
||||
} else if (!REGEXP_PWD.test(value)) {
|
||||
callback(
|
||||
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
|
||||
);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export { loginRules };
|
||||
@@ -0,0 +1,5 @@
|
||||
import bg from "@/assets/login/bg.png";
|
||||
import avatar from "@/assets/login/avatar.svg?component";
|
||||
import illustration from "@/assets/login/illustration.svg?component";
|
||||
|
||||
export { bg, avatar, illustration };
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { hasAuth, getAuths } from "@/router/utils";
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionButtonRouter"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2!">当前拥有的code列表:{{ getAuths() }}</p>
|
||||
|
||||
<el-card shadow="never" class="mb-2">
|
||||
<template #header>
|
||||
<div class="card-header">组件方式判断权限</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<Auth value="permission:btn:add">
|
||||
<el-button plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
</Auth>
|
||||
<Auth :value="['permission:btn:edit']">
|
||||
<el-button plain type="primary">
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
</Auth>
|
||||
<Auth
|
||||
:value="[
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
]"
|
||||
>
|
||||
<el-button plain type="danger">
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</Auth>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mb-2">
|
||||
<template #header>
|
||||
<div class="card-header">函数方式判断权限</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="
|
||||
hasAuth([
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
])
|
||||
"
|
||||
plain
|
||||
type="danger"
|
||||
>
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
指令方式判断权限(该方式不能动态修改权限)
|
||||
</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<el-button v-auth="'permission:btn:add'" plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
<el-button v-auth="['permission:btn:edit']" plain type="primary">
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
<el-button
|
||||
v-auth="[
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
]"
|
||||
plain
|
||||
type="danger"
|
||||
>
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { hasPerms } from "@/utils/auth";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
|
||||
const { permissions } = useUserStoreHook();
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionButtonLogin"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2!">当前拥有的code列表:{{ permissions }}</p>
|
||||
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
|
||||
*:*:* 代表拥有全部按钮级别权限
|
||||
</p>
|
||||
|
||||
<el-card shadow="never" class="mb-2">
|
||||
<template #header>
|
||||
<div class="card-header">组件方式判断权限</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<Perms value="permission:btn:add">
|
||||
<el-button plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
</Perms>
|
||||
<Perms :value="['permission:btn:edit']">
|
||||
<el-button plain type="primary">
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
</Perms>
|
||||
<Perms
|
||||
:value="[
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
]"
|
||||
>
|
||||
<el-button plain type="danger">
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</Perms>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mb-2">
|
||||
<template #header>
|
||||
<div class="card-header">函数方式判断权限</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasPerms(['permission:btn:edit'])"
|
||||
plain
|
||||
type="primary"
|
||||
>
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="
|
||||
hasPerms([
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
])
|
||||
"
|
||||
plain
|
||||
type="danger"
|
||||
>
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
指令方式判断权限(该方式不能动态修改权限)
|
||||
</div>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<el-button v-perms="'permission:btn:add'" plain type="warning">
|
||||
拥有code:'permission:btn:add' 权限可见
|
||||
</el-button>
|
||||
<el-button v-perms="['permission:btn:edit']" plain type="primary">
|
||||
拥有code:['permission:btn:edit'] 权限可见
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="[
|
||||
'permission:btn:add',
|
||||
'permission:btn:edit',
|
||||
'permission:btn:delete'
|
||||
]"
|
||||
plain
|
||||
type="danger"
|
||||
>
|
||||
拥有code:['permission:btn:add', 'permission:btn:edit',
|
||||
'permission:btn:delete'] 权限可见
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { initRouter } from "@/router/utils";
|
||||
import { storageLocal } from "@pureadmin/utils";
|
||||
import { type CSSProperties, ref, computed } from "vue";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionPage"
|
||||
});
|
||||
|
||||
const elStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
width: "85vw",
|
||||
justifyContent: "start"
|
||||
};
|
||||
});
|
||||
|
||||
const username = ref(useUserStoreHook()?.username);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "admin",
|
||||
label: "管理员角色"
|
||||
},
|
||||
{
|
||||
value: "common",
|
||||
label: "普通角色"
|
||||
}
|
||||
];
|
||||
|
||||
function onChange() {
|
||||
useUserStoreHook()
|
||||
.loginByUsername({ username: username.value, password: "admin123" })
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
storageLocal().removeItem("async-routes");
|
||||
usePermissionStoreHook().clearAllCachePage();
|
||||
initRouter();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2!">
|
||||
模拟后台根据不同角色返回对应路由,观察左侧菜单变化(管理员角色可查看系统管理菜单、普通角色不可查看系统管理菜单)
|
||||
</p>
|
||||
<el-card shadow="never" :style="elStyle">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>当前角色:{{ username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-select v-model="username" class="w-[160px]!" @change="onChange">
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
listRoles,
|
||||
updateRole,
|
||||
type Role,
|
||||
type RolePayload
|
||||
} from "@/api/access";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import Delete from "~icons/ep/delete";
|
||||
|
||||
defineOptions({
|
||||
name: "RoleManagement"
|
||||
});
|
||||
|
||||
type RoleFormState = RolePayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const codePattern = /^[a-z][a-z0-9_]*$/;
|
||||
const tableLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const roles = ref<Role[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
keyword: ""
|
||||
});
|
||||
|
||||
const form = reactive<RoleFormState>({
|
||||
code: "",
|
||||
name: "",
|
||||
description: ""
|
||||
});
|
||||
|
||||
const rules: FormRules<RoleFormState> = {
|
||||
code: [
|
||||
{ required: true, message: "请输入角色编码", trigger: "blur" },
|
||||
{
|
||||
pattern: codePattern,
|
||||
message: "角色编码只能使用小写字母、数字和下划线,并以字母开头",
|
||||
trigger: "blur"
|
||||
},
|
||||
{ max: 50, message: "角色编码不能超过 50 个字符", trigger: "blur" }
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: "请输入角色名称", trigger: "blur" },
|
||||
{ max: 50, message: "角色名称不能超过 50 个字符", trigger: "blur" }
|
||||
],
|
||||
description: [
|
||||
{ max: 255, message: "角色说明不能超过 255 个字符", trigger: "blur" }
|
||||
]
|
||||
};
|
||||
|
||||
const filteredRoles = computed(() => {
|
||||
const keyword = query.keyword.trim().toLowerCase();
|
||||
|
||||
if (keyword.length === 0) {
|
||||
return roles.value;
|
||||
}
|
||||
|
||||
return roles.value.filter(role => {
|
||||
return (
|
||||
role.code.toLowerCase().includes(keyword) ||
|
||||
role.name.toLowerCase().includes(keyword) ||
|
||||
(role.description ?? "").toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const describedCount = computed(
|
||||
() => roles.value.filter(item => Boolean(item.description)).length
|
||||
);
|
||||
const systemRoleCount = computed(
|
||||
() => roles.value.filter(item => item.code === "admin").length
|
||||
);
|
||||
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
code: "",
|
||||
name: "",
|
||||
description: ""
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): RolePayload {
|
||||
const description = form.description?.trim();
|
||||
|
||||
return {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
description: description ? description : null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listRoles();
|
||||
roles.value = result.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.keyword = "";
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.keyword = query.keyword.trim();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row: Role) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description ?? ""
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate();
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (form.id) {
|
||||
await updateRole(form.id, payload);
|
||||
ElMessage.success("角色信息已更新");
|
||||
} else {
|
||||
await createRole(payload);
|
||||
ElMessage.success("角色已创建");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存角色失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRole(row: Role) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
|
||||
"删除角色",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "删除",
|
||||
cancelButtonText: "取消"
|
||||
}
|
||||
);
|
||||
await deleteRole(row.id);
|
||||
ElMessage.success("角色已删除");
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "删除角色失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchRoles);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="management-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">权限角色基础数据</p>
|
||||
<h1>角色管理</h1>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增角色
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="summary-strip">
|
||||
<div class="summary-item">
|
||||
<span>总角色</span>
|
||||
<strong>{{ roles.length }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>已配置说明</span>
|
||||
<strong>{{ describedCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>系统角色</span>
|
||||
<strong>{{ systemRoleCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前筛选</span>
|
||||
<strong>{{ filteredRoles.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
placeholder="搜索编码、名称或说明"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="filteredRoles"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="management-table"
|
||||
>
|
||||
<el-table-column prop="name" label="角色" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="role-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.code }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.description || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeRole(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="management-form"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="角色编码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
maxlength="50"
|
||||
placeholder="例如 store_manager"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="角色说明" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
placeholder="请输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.management-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.management-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.management-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.management-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.keyword-input,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
type FormInstance,
|
||||
type FormRules
|
||||
} from "element-plus";
|
||||
import {
|
||||
createStore,
|
||||
deleteStore,
|
||||
listStores,
|
||||
updateStore,
|
||||
type Store,
|
||||
type StorePayload,
|
||||
type StoreStatus
|
||||
} from "@/api/access";
|
||||
|
||||
import Plus from "~icons/ep/plus";
|
||||
import Search from "~icons/ep/search";
|
||||
import Refresh from "~icons/ep/refresh";
|
||||
import EditPen from "~icons/ep/edit-pen";
|
||||
import Delete from "~icons/ep/delete";
|
||||
import CircleCheck from "~icons/ep/circle-check";
|
||||
import CircleClose from "~icons/ep/circle-close";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreManagement"
|
||||
});
|
||||
|
||||
type StoreFormState = StorePayload & {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
const tableLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const stores = ref<Store[]>([]);
|
||||
|
||||
const query = reactive({
|
||||
status: undefined as StoreStatus | undefined,
|
||||
keyword: ""
|
||||
});
|
||||
|
||||
const form = reactive<StoreFormState>({
|
||||
name: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
status: "ACTIVE"
|
||||
});
|
||||
|
||||
const rules: FormRules<StoreFormState> = {
|
||||
name: [
|
||||
{ required: true, message: "请输入门店名称", trigger: "blur" },
|
||||
{ max: 100, message: "门店名称不能超过 100 个字符", trigger: "blur" }
|
||||
],
|
||||
address: [
|
||||
{ max: 255, message: "门店地址不能超过 255 个字符", trigger: "blur" }
|
||||
],
|
||||
phone: [{ max: 30, message: "联系电话不能超过 30 个字符", trigger: "blur" }],
|
||||
status: [{ required: true, message: "请选择门店状态", trigger: "change" }]
|
||||
};
|
||||
|
||||
const filteredStores = computed(() => {
|
||||
const keyword = query.keyword.trim().toLowerCase();
|
||||
|
||||
return stores.value.filter(store => {
|
||||
const matchedStatus =
|
||||
query.status === undefined || store.status === query.status;
|
||||
const matchedKeyword =
|
||||
keyword.length === 0 ||
|
||||
store.name.toLowerCase().includes(keyword) ||
|
||||
(store.address ?? "").toLowerCase().includes(keyword) ||
|
||||
(store.phone ?? "").toLowerCase().includes(keyword);
|
||||
|
||||
return matchedStatus && matchedKeyword;
|
||||
});
|
||||
});
|
||||
|
||||
const activeCount = computed(
|
||||
() => stores.value.filter(item => item.status === "ACTIVE").length
|
||||
);
|
||||
const inactiveCount = computed(
|
||||
() => stores.value.filter(item => item.status === "INACTIVE").length
|
||||
);
|
||||
const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const message = (
|
||||
error as { response?: { data?: { error?: { message?: string } } } }
|
||||
)?.response?.data?.error?.message;
|
||||
|
||||
if (message) return message;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function resetFormState() {
|
||||
Object.assign(form, {
|
||||
id: undefined,
|
||||
name: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
status: "ACTIVE"
|
||||
});
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function buildPayload(): StorePayload {
|
||||
const address = form.address?.trim();
|
||||
const phone = form.phone?.trim();
|
||||
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
address: address ? address : null,
|
||||
phone: phone ? phone : null,
|
||||
status: form.status
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchStores() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const result = await listStores({ includeInactive: true });
|
||||
stores.value = result.data;
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "加载门店列表失败"));
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
query.status = undefined;
|
||||
query.keyword = "";
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.keyword = query.keyword.trim();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
resetFormState();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row: Store) {
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
address: row.address ?? "",
|
||||
phone: row.phone ?? "",
|
||||
status: row.status
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate();
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
|
||||
if (form.id) {
|
||||
await updateStore(form.id, payload);
|
||||
ElMessage.success("门店信息已更新");
|
||||
} else {
|
||||
await createStore(payload);
|
||||
ElMessage.success("门店已创建");
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchStores();
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, "保存门店失败"));
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: Store) {
|
||||
const nextStatus: StoreStatus =
|
||||
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
|
||||
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认${action}门店「${row.name}」?`,
|
||||
"状态变更",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: action,
|
||||
cancelButtonText: "取消"
|
||||
}
|
||||
);
|
||||
await updateStore(row.id, { status: nextStatus });
|
||||
ElMessage.success(`已${action}`);
|
||||
fetchStores();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "状态变更失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStore(row: Store) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`,
|
||||
"删除门店",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "删除",
|
||||
cancelButtonText: "取消"
|
||||
}
|
||||
);
|
||||
await deleteStore(row.id);
|
||||
ElMessage.success("门店已删除");
|
||||
fetchStores();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error(getErrorMessage(error, "删除门店失败"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="management-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">门店基础数据</p>
|
||||
<h1>门店管理</h1>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增门店
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="summary-strip">
|
||||
<div class="summary-item">
|
||||
<span>总门店</span>
|
||||
<strong>{{ stores.length }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>启用门店</span>
|
||||
<strong>{{ activeCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>停用门店</span>
|
||||
<strong>{{ inactiveCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>当前筛选</span>
|
||||
<strong>{{ filteredStores.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="query.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
class="toolbar-control"
|
||||
>
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
placeholder="搜索门店、地址或电话"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-shell">
|
||||
<el-table
|
||||
v-loading="tableLoading"
|
||||
:data="filteredStores"
|
||||
row-key="id"
|
||||
stripe
|
||||
class="management-table"
|
||||
>
|
||||
<el-table-column prop="name" label="门店" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<strong class="primary-text">{{ row.name }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.address || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="联系电话" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.phone || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
|
||||
effect="light"
|
||||
>
|
||||
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.updatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="openEditDialog(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeStore(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="management-form"
|
||||
>
|
||||
<el-form-item label="门店名称" prop="name">
|
||||
<el-input v-model="form.name" maxlength="100" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
maxlength="30"
|
||||
placeholder="请输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button label="ACTIVE">启用</el-radio-button>
|
||||
<el-radio-button label="INACTIVE">停用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="门店地址" prop="address">
|
||||
<el-input
|
||||
v-model="form.address"
|
||||
type="textarea"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
placeholder="请输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.management-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.table-shell {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
padding: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.management-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primary-text {
|
||||
font-weight: 650;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.management-form {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.management-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toolbar-control,
|
||||
.keyword-input,
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "Welcome"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Pure-Admin-Thin(非国际化版本)</h1>
|
||||
</template>
|
||||
Reference in New Issue
Block a user