chore: migrate to pnpm workspaces monorepo with Turborepo

- Restructure directories: apps/api, apps/admin, apps/website
- Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore
- Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website
- Add shared packages: packages/tsconfig, packages/shared-types
- Add pnpm.onlyBuiltDependencies for native builds
- Update docs: README.md, structs.md
- All three projects build successfully
This commit is contained in:
yeuimu
2026-07-11 16:54:05 +08:00
parent 69945b8749
commit 7e04877bb6
155 changed files with 20134 additions and 14393 deletions
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
import type {
Category,
CategoryTree,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@/types'
import { categoriesApi } from '@/api/categories'
const loading = ref(false)
const tree = ref<CategoryTree[]>([])
async function fetchTree() {
loading.value = true
try {
tree.value = await categoriesApi.getCategoryTree()
} finally {
loading.value = false
}
}
// ---------- Cascader ----------
interface CascaderNode {
value: string
label: string
children?: CascaderNode[]
}
const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
function rebuildCascader() {
cascaderOptions.value = buildCascader(tree.value)
}
// ---------- Dialog ----------
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
categoryName: '',
categoryIcon: '',
parentCategoryId: '',
})
const dialogRules = {
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
async function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
rebuildCascader()
dialogVisible.value = true
}
async function openEditDialog(c: Category) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: c.id,
categoryName: c.categoryName,
categoryIcon: c.categoryIcon || '',
parentCategoryId: c.parentCategoryId || '',
})
rebuildCascader()
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateCategoryRequest = {
categoryName: dialogForm.categoryName,
categoryIcon: dialogForm.categoryIcon || undefined,
parentCategoryId: dialogForm.parentCategoryId || undefined,
}
if (dialogMode.value === 'create') {
await categoriesApi.createCategory(payload)
ElMessage.success('分类创建成功')
} else {
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
ElMessage.success('分类更新成功')
}
dialogVisible.value = false
fetchTree()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(c: Category) {
try {
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await categoriesApi.deleteCategory(c.id)
ElMessage.success('删除成功')
fetchTree()
}
onMounted(fetchTree)
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="toolbar">
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增分类</span>
</el-button>
<el-button @click="fetchTree">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
<el-table
v-loading="loading"
:data="tree"
border
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
>
<el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }">
<el-image
v-if="row.categoryIcon"
:src="row.categoryIcon"
:preview-src-list="[row.categoryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }">
{{ row.parent?.categoryName || '-' }}
</template>
</el-table-column>
<el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }">
{{ row._count?.children ?? row.children?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无分类" />
</template>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
width="520px"
destroy-on-close
>
<el-form
ref="dialogRef"
:model="dialogForm"
:rules="dialogRules"
label-width="100px"
>
<el-form-item label="名称" prop="categoryName">
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类">
<el-cascader
v-model="dialogForm.parentCategoryId"
:options="cascaderOptions"
:props="{
checkStrictly: true,
value: 'value',
label: 'label',
children: 'children',
emitPath: false,
}"
placeholder="顶级(可选)"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Country,
CreateCountryRequest,
UpdateCountryRequest,
CountryFilter,
} from '@/types'
import { countriesApi } from '@/api/countries'
const loading = ref(false)
const list = ref<Country[]>([])
const total = ref(0)
const filter = reactive<Required<CountryFilter>>({
countryName: '',
page: 1,
pageSize: 10,
})
async function fetchList() {
loading.value = true
try {
const res = await countriesApi.getCountriesList(filter)
const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.countryName = ''
filter.page = 1
fetchList()
}
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
countryName: '',
countryIcon: '',
})
const dialogRules = {
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
dialogVisible.value = true
}
function openEditDialog(c: Country) {
dialogMode.value = 'edit'
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateCountryRequest = {
countryName: dialogForm.countryName,
countryIcon: dialogForm.countryIcon || undefined,
}
if (dialogMode.value === 'create') {
await countriesApi.createCountry(payload)
ElMessage.success('国家创建成功')
} else {
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
ElMessage.success('国家更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(c: Country) {
try {
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await countriesApi.deleteCountry(c.id)
ElMessage.success('删除成功')
fetchList()
}
onMounted(fetchList)
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.countryName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增国家</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="图标" width="80">
<template #default="{ row }: { row: Country }">
<el-image
v-if="row.countryIcon"
:src="row.countryIcon"
:preview-src-list="[row.countryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无国家" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="名称" prop="countryName">
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
</el-form-item>
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
})
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
],
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
await authStore.login({ username: form.username, password: form.password })
ElMessage.success('登录成功')
router.push('/')
} catch (err) {
// Error toast is shown by axios response interceptor
console.error('Login failed', err)
} finally {
loading.value = false
}
})
}
</script>
<template>
<div class="login-page">
<div class="login-bg" />
<div class="login-card">
<div class="login-brand">
<div class="brand-logo">Inkreach</div>
<div class="brand-subtitle">官网后台</div>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
size="large"
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
clearable
autocomplete="username"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
autocomplete="current-password"
@keyup.enter="handleSubmit"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="login-button"
:loading="loading"
native-type="submit"
@click="handleSubmit"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>© {{ new Date().getFullYear() }} Inkreach</span>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-bg {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
z-index: 0;
}
.login-card {
position: relative;
z-index: 1;
width: 420px;
max-width: calc(100vw - 32px);
padding: 40px 36px 28px;
background: rgba(255, 255, 255, 0.97);
border-radius: 12px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
}
.login-brand {
text-align: center;
margin-bottom: 24px;
}
.brand-logo {
font-size: 28px;
font-weight: 700;
color: var(--brand-color);
letter-spacing: 0.5px;
}
.brand-subtitle {
font-size: 13px;
color: #6b7280;
margin-top: 6px;
}
.login-button {
width: 100%;
height: 44px;
font-weight: 600;
font-size: 15px;
}
.login-footer {
text-align: center;
color: #9ca3af;
font-size: 12px;
margin-top: 8px;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #374151;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Position,
CreatePositionRequest,
UpdatePositionRequest,
PositionFilter,
Country,
Category,
CategoryTree,
} from '@/types'
import { positionsApi } from '@/api/positions'
import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories'
const loading = ref(false)
const list = ref<Position[]>([])
const total = ref(0)
const countries = ref<Country[]>([])
const categoriesTree = ref<CategoryTree[]>([])
const filter = reactive<Required<PositionFilter>>({
countryId: '',
categoryId: '',
page: 1,
pageSize: 10,
})
async function loadLookups() {
const [c, ct] = await Promise.all([
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
categoriesApi.getCategoryTree(),
])
countries.value = c.items
categoriesTree.value = ct
}
async function fetchList() {
loading.value = true
try {
const res = await positionsApi.getPositionsList(filter)
const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.countryId = ''
filter.categoryId = ''
filter.page = 1
fetchList()
}
// ---------- Cascader ----------
interface CascaderNode {
value: string
label: string
children?: CascaderNode[]
}
const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
// ---------- Dialog ----------
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
indexVal: 0,
countryId: '',
categoryId: '',
})
const dialogRules = {
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
}
function rebuildCascader() {
cascaderOptions.value = buildCascader(categoriesTree.value)
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
rebuildCascader()
dialogVisible.value = true
}
function openEditDialog(p: Position) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: p.id,
indexVal: p.indexVal,
countryId: p.countryId || '',
categoryId: p.categoryId || '',
})
rebuildCascader()
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreatePositionRequest = {
indexVal: Number(dialogForm.indexVal),
countryId: dialogForm.countryId || undefined,
categoryId: dialogForm.categoryId || undefined,
}
if (dialogMode.value === 'create') {
await positionsApi.createPosition(payload)
ElMessage.success('位置创建成功')
} else {
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
ElMessage.success('位置更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(p: Position) {
try {
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await positionsApi.deletePosition(p.id)
ElMessage.success('删除成功')
fetchList()
}
function getCategoryName(p: Position): string {
if (!p.category) return p.categoryId || '-'
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
}
onMounted(async () => {
await loadLookups()
await fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-select
v-model="filter.countryId"
placeholder="请选择国家"
clearable
@change="handleSearch"
>
<el-option
v-for="c in countries"
:key="c.id"
:label="c.countryName"
:value="c.id"
/>
</el-select>
<el-cascader
v-model="filter.categoryId"
:options="cascaderOptions"
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
placeholder="请选择分类"
clearable
@change="handleSearch"
/>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增位置</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }">
{{ row.country?.countryName || '-' }}
</template>
</el-table-column>
<el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }">
{{ getCategoryName(row) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无位置" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
width="520px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="排序值" prop="indexVal">
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="国家">
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
<el-option
v-for="c in countries"
:key="c.id"
:label="c.countryName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类">
<el-cascader
v-model="dialogForm.categoryId"
:options="cascaderOptions"
:props="{
checkStrictly: true,
value: 'value',
label: 'label',
children: 'children',
emitPath: false,
}"
placeholder="可选"
clearable
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { markRaw, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Goods, Refresh } from '@element-plus/icons-vue'
import GoodsView from '@/views/goods/GoodsView.vue'
import SyncView from '@/views/sync/SyncView.vue'
const route = useRoute()
const router = useRouter()
const tabs = [
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
]
const activeTab = ref((route.query.tab as string) || 'goods')
watch(activeTab, (val) => {
router.replace({ query: { ...route.query, tab: val } })
})
</script>
<template>
<div class="pm-wrapper">
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
<el-tab-pane
v-for="t in tabs"
:key="t.name"
:name="t.name"
>
<template #label>
<span class="pm-tab-label">
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
{{ t.label }}
</span>
</template>
<component :is="t.comp" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
.pm-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pm-tab-icon { font-size: 14px; }
</style>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Box } from '@element-plus/icons-vue'
import type { SyncLog } from '@/types'
import { syncApi } from '@/api/sync'
const logs = ref<SyncLog[]>([])
const loading = ref(false)
const syncing = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
async function refreshLogs() {
loading.value = true
try {
const data = await syncApi.getSyncStatus(50) as any
logs.value = Array.isArray(data) ? data : []
} catch (err) {
console.warn('Failed to fetch sync status', err)
} finally {
loading.value = false
}
}
async function handleSync() {
try {
await ElMessageBox.confirm(
'确定立即执行产品同步吗?此操作可能需要一些时间。',
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch { return }
syncing.value = true
try {
await syncApi.syncProducts()
ElMessage.success('产品同步完成')
await refreshLogs()
} catch {
ElMessage.error('产品同步失败')
} finally {
syncing.value = false
}
}
function formatDate(s?: string): string {
if (!s) return '-'
return new Date(s).toLocaleString()
}
onMounted(() => {
refreshLogs()
timer = setInterval(refreshLogs, 30_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="page-container">
<div class="sync-cards">
<el-card class="sync-card">
<template #header>
<div class="sync-card-header">
<div class="sync-card-title">
<el-icon><Box /></el-icon>
<span>产品同步</span>
</div>
</div>
</template>
<p class="sync-card-desc">
从上游拉取最新产品和原产品并与本地数据库进行同步
</p>
<el-button type="primary" :loading="syncing" @click="handleSync">
<el-icon><Refresh /></el-icon>
<span>执行产品同步</span>
</el-button>
</el-card>
</div>
<div class="page-card logs-card">
<div class="logs-toolbar">
<h3 class="logs-title">同步日志</h3>
<el-button @click="refreshLogs">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
<el-table v-loading="loading" :data="logs" border stripe>
<el-table-column label="状态" width="100">
<template #default="{ row }: { row: SyncLog }">
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="200">
<template #default="{ row }: { row: SyncLog }">
{{ formatDate(row.startTime) }}
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
<template #empty>
<el-empty description="暂无同步记录" />
</template>
</el-table>
</div>
</div>
</template>
<style scoped>
.sync-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.sync-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.sync-card-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
}
.sync-card-title :deep(.el-icon) {
color: var(--brand-color);
}
.sync-card-desc {
color: #6b7280;
font-size: 13px;
margin: 0 0 16px;
min-height: 40px;
}
.logs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.logs-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
import type {
Tag,
TagGroup,
CreateTagRequest,
UpdateTagRequest,
TagFilter,
} from '@/types'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
const loading = ref(false)
const list = ref<Tag[]>([])
const total = ref(0)
const allGroups = ref<TagGroup[]>([])
const filter = reactive<Required<TagFilter>>({
tagName: '',
page: 1,
pageSize: 10,
})
async function fetchList() {
loading.value = true
try {
const res = await tagsApi.getTagsList(filter)
const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
}
async function loadGroups() {
allGroups.value = await tagGroupsApi.getTagGroupsList()
}
function handleSearch() {
filter.page = 1
fetchList()
}
function handleReset() {
filter.tagName = ''
filter.page = 1
fetchList()
}
const dialogRef = ref()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
id: undefined,
tagName: '',
tagColor: '#ff6800',
tagFontColor: '#ffffff',
timing: '',
tagGroupId: null,
})
const dialogRules = {
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
dialogVisible.value = true
}
function openEditDialog(t: Tag) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: t.id,
tagName: t.tagName,
tagColor: t.tagColor || '#ff6800',
tagFontColor: t.tagFontColor || '#ffffff',
timing: t.timing || '',
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
})
dialogVisible.value = true
}
async function handleSubmit() {
if (!dialogRef.value) return
await dialogRef.value.validate(async (valid: boolean) => {
if (!valid) return
dialogLoading.value = true
try {
const payload: CreateTagRequest = {
tagName: dialogForm.tagName,
tagColor: dialogForm.tagColor || undefined,
tagFontColor: dialogForm.tagFontColor || undefined,
timing: dialogForm.timing || undefined,
tagGroupId: dialogForm.tagGroupId ?? undefined,
}
if (dialogMode.value === 'create') {
await tagsApi.createTag(payload)
ElMessage.success('标签创建成功')
} else {
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
ElMessage.success('标签更新成功')
}
dialogVisible.value = false
fetchList()
} finally {
dialogLoading.value = false
}
})
}
async function handleDelete(t: Tag) {
try {
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await tagsApi.deleteTag(t.id)
ElMessage.success('删除成功')
fetchList()
}
onMounted(() => {
fetchList()
loadGroups()
})
</script>
<template>
<div class="page-container">
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.tagName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>新增标签</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }">
<span
v-if="row.tagColor"
class="tag-preview"
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
>{{ row.tagName }}</span>
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="背景色" width="140">
<template #default="{ row }: { row: Tag }">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="字体色" width="140">
<template #default="{ row }: { row: Tag }">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="所属分组" min-width="140">
<template #default="{ row }: { row: Tag }">
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
<span v-else style="color: #999;">未分组</span>
</template>
</el-table-column>
<el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无标签" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="名称" prop="tagName">
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="背景色">
<el-color-picker v-model="dialogForm.tagColor" />
<span class="color-readout">{{ dialogForm.tagColor }}</span>
</el-form-item>
<el-form-item label="字体色">
<el-color-picker v-model="dialogForm.tagFontColor" />
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
</el-form-item>
<el-form-item label="所属分组">
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
<el-option
v-for="g in allGroups"
:key="g.id"
:label="g.groupName"
:value="Number(g.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="定时">
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.filter-spacer {
flex: 1;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.color-cell {
display: flex;
align-items: center;
gap: 8px;
}
.color-swatch {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.color-hex {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #6b7280;
}
.color-readout {
margin-left: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #6b7280;
}
.tag-preview {
display: inline-block;
padding: 2px 12px;
border-radius: 4px;
font-size: 13px;
white-space: nowrap;
}
</style>