重构项目,方便开发

This commit is contained in:
super
2026-03-19 13:15:47 +08:00
parent fb2e5ccea5
commit 1e63ab291b
38 changed files with 5940 additions and 0 deletions

3
frontend-vue/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,20 @@
<template>
<div class="page-shell" :class="themeClass">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
theme?: 'light' | 'dark'
}>(),
{
theme: 'light',
},
)
const themeClass = computed(() => `theme-${props.theme}`)
</script>

8
frontend-vue/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import App from '@/App.vue'
import router from '@/router'
createApp(App).use(ElementPlus).use(router).mount('#app')

View File

@@ -0,0 +1,584 @@
<template>
<PageShell theme="light">
<div class="admin-page">
<div class="admin-nav">
<router-link to="/home"> 返回首页</router-link>
</div>
<h1 class="page-title">管理后台</h1>
<el-tabs v-model="activeTab" class="admin-tabs" @tab-change="handleTabChange">
<el-tab-pane label="用户管理" name="users">
<div class="admin-grid">
<el-card class="form-card">
<template #header>创建用户</template>
<p class="card-tip">无注册入口仅管理员可在此创建用户层级超级管理员 管理员 普通号</p>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="createForm.username" placeholder="用户名至少2个字符" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="createForm.password"
type="password"
show-password
placeholder="密码至少6个字符"
/>
</el-form-item>
<el-form-item v-if="showRoleSelect" label="角色">
<el-select v-model="createForm.role">
<el-option label="普通号" value="normal" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item v-if="showCreatedBySelect" label="所属管理员">
<el-select v-model="createForm.createdById" placeholder="请选择管理员">
<el-option
v-for="admin in adminsList"
:key="admin.id"
:label="admin.username"
:value="admin.id"
/>
</el-select>
</el-form-item>
<el-button type="primary" :loading="createLoading" @click="handleCreateUser">创建用户</el-button>
</el-form>
</el-card>
<el-card class="table-card">
<template #header>用户列表</template>
<div class="toolbar">
<el-input
v-model="searchUsername"
placeholder="输入用户名关键字"
clearable
@keyup.enter="loadUsers(1)"
/>
<el-select
v-if="isSuperAdmin"
v-model="filterCreatedBy"
placeholder="所属管理员"
clearable
>
<el-option
v-for="admin in adminsList"
:key="admin.id"
:label="admin.username"
:value="String(admin.id)"
/>
</el-select>
<el-button type="primary" @click="loadUsers(1)">查询</el-button>
</div>
<el-table :data="users" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="角色" width="120">
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
</el-table-column>
<el-table-column prop="creator_username" label="所属管理员" min-width="140">
<template #default="{ row }">{{ row.creator_username || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="removeUser(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next"
:current-page="userPage"
:page-size="pageSize"
:total="userTotal"
@current-change="loadUsers"
/>
</el-card>
</div>
</el-tab-pane>
<el-tab-pane label="查看生成记录" name="history">
<el-card class="table-card">
<template #header>筛选条件</template>
<div class="toolbar">
<el-select v-model="filterUserId" placeholder="全部用户" clearable>
<el-option
v-for="option in userOptions"
:key="option.id"
:label="`${option.username} (${roleLabel(option.role)})`"
:value="String(option.id)"
/>
</el-select>
<el-date-picker
v-model="filterTimeStart"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<el-date-picker
v-model="filterTimeEnd"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
/>
<el-button type="primary" @click="loadHistory(1)">查询</el-button>
</div>
</el-card>
<el-card class="table-card history-card">
<template #header>生成记录</template>
<el-table :data="historyRows" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户" min-width="120">
<template #default="{ row }">{{ row.username || '-' }}</template>
</el-table-column>
<el-table-column prop="panel_type" label="类型" min-width="140">
<template #default="{ row }">{{ row.panel_type || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="180" />
<el-table-column label="结果预览" min-width="220">
<template #default="{ row }">
<div v-if="getHistoryPreviewUrls(row).length" class="history-preview">
<el-image
v-for="(url, index) in getHistoryPreviewUrls(row)"
:key="`${row.id}-${index}`"
:src="url"
fit="cover"
:preview-src-list="getHistoryPreviewUrls(row)"
class="preview-image"
/>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next"
:current-page="historyPage"
:page-size="pageSize"
:total="historyTotal"
@current-change="loadHistory"
/>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" title="编辑用户" width="420px">
<el-form label-position="top">
<el-form-item label="用户名">
<el-input :model-value="editForm.username" readonly />
</el-form-item>
<el-form-item label="新密码(不修改留空)">
<el-input
v-model="editForm.password"
type="password"
show-password
placeholder="留空则不修改密码"
/>
</el-form-item>
<el-form-item v-if="showEditRoleSelect" label="角色">
<el-select v-model="editForm.role">
<el-option label="普通号" value="normal" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item v-if="editForm.creatorUsername" label="所属管理员">
<el-input :model-value="editForm.creatorUsername" readonly />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="saveUser">保存</el-button>
</template>
</el-dialog>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createAdminUser,
deleteAdminUser,
getAdminHistory,
getAdminUsers,
updateAdminUser,
type AdminHistoryItem,
type AdminUserItem,
} from '@/shared/api/admin'
interface UserOption extends AdminUserItem {}
const activeTab = ref<'users' | 'history'>('users')
const pageSize = 15
const currentUserRole = ref<'super_admin' | 'admin' | 'normal' | string>('admin')
const adminsList = ref<{ id: number; username: string }[]>([])
const users = ref<AdminUserItem[]>([])
const userTotal = ref(0)
const userPage = ref(1)
const searchUsername = ref('')
const filterCreatedBy = ref('')
const createLoading = ref(false)
const createForm = reactive({
username: '',
password: '',
role: 'normal',
createdById: undefined as number | undefined,
})
const userOptions = ref<UserOption[]>([])
const filterUserId = ref('')
const filterTimeStart = ref('')
const filterTimeEnd = ref('')
const historyRows = ref<AdminHistoryItem[]>([])
const historyTotal = ref(0)
const historyPage = ref(1)
const editVisible = ref(false)
const editLoading = ref(false)
const editForm = reactive({
id: 0,
username: '',
password: '',
role: 'normal',
creatorUsername: '',
})
const isSuperAdmin = computed(() => currentUserRole.value === 'super_admin')
const showRoleSelect = computed(() => isSuperAdmin.value)
const showCreatedBySelect = computed(
() => isSuperAdmin.value && createForm.role === 'normal' && adminsList.value.length > 0,
)
const showEditRoleSelect = computed(() => isSuperAdmin.value)
function roleLabel(role?: string) {
if (role === 'super_admin') return '超级管理员'
if (role === 'admin') return '管理员'
return '普通号'
}
function buildUsersQuery(page: number) {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
})
if (searchUsername.value.trim()) {
params.set('username', searchUsername.value.trim())
}
if (filterCreatedBy.value) {
params.set('created_by_id', filterCreatedBy.value)
}
return params.toString()
}
async function loadUsers(page = 1) {
userPage.value = page
try {
const result = await getAdminUsers(buildUsersQuery(page))
if (!result.success) {
ElMessage.error(result.error || '加载用户失败')
return
}
currentUserRole.value = result.current_user_role || 'admin'
adminsList.value = result.admins || []
users.value = result.items || []
userTotal.value = result.total || 0
if (!isSuperAdmin.value) {
createForm.role = 'normal'
createForm.createdById = undefined
filterCreatedBy.value = ''
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '请求失败')
}
}
async function loadUserOptions() {
try {
const result = await getAdminUsers('page=1&page_size=999')
if (result.success) {
userOptions.value = result.items || []
}
} catch {
userOptions.value = []
}
}
function buildHistoryQuery(page: number) {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
})
if (filterUserId.value) {
params.set('user_id', filterUserId.value)
}
if (filterTimeStart.value) {
params.set('time_start', filterTimeStart.value)
}
if (filterTimeEnd.value) {
params.set('time_end', filterTimeEnd.value)
}
return params.toString()
}
async function loadHistory(page = 1) {
historyPage.value = page
try {
const result = await getAdminHistory(buildHistoryQuery(page))
if (!result.success) {
ElMessage.error(result.error || '加载记录失败')
return
}
historyRows.value = result.items || []
historyTotal.value = result.total || 0
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '请求失败')
}
}
async function handleCreateUser() {
const username = createForm.username.trim()
if (username.length < 2) {
ElMessage.warning('用户名至少2个字符')
return
}
if (createForm.password.length < 6) {
ElMessage.warning('密码至少6个字符')
return
}
if (showCreatedBySelect.value && !createForm.createdById) {
ElMessage.warning('请选择所属管理员')
return
}
createLoading.value = true
try {
const payload: {
username: string
password: string
role: string
created_by_id?: number
} = {
username,
password: createForm.password,
role: isSuperAdmin.value ? createForm.role : 'normal',
}
if (showCreatedBySelect.value && createForm.createdById) {
payload.created_by_id = createForm.createdById
}
const result = await createAdminUser(payload)
if (!result.success) {
ElMessage.error(result.error || '创建失败')
return
}
ElMessage.success(result.msg || '创建成功')
createForm.username = ''
createForm.password = ''
createForm.role = 'normal'
createForm.createdById = undefined
await loadUsers(1)
await loadUserOptions()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '请求失败')
} finally {
createLoading.value = false
}
}
function openEditDialog(user: AdminUserItem) {
editForm.id = user.id
editForm.username = user.username || ''
editForm.password = ''
editForm.role = user.role === 'admin' ? 'admin' : 'normal'
editForm.creatorUsername = user.creator_username || ''
editVisible.value = true
}
async function saveUser() {
editLoading.value = true
try {
const payload: { password?: string; role?: string } = {}
if (editForm.password) {
if (editForm.password.length < 6) {
ElMessage.warning('密码至少6个字符')
return
}
payload.password = editForm.password
}
if (showEditRoleSelect.value) {
payload.role = editForm.role
}
const result = await updateAdminUser(editForm.id, payload)
if (!result.success) {
ElMessage.error(result.error || '保存失败')
return
}
editVisible.value = false
ElMessage.success(result.msg || '保存成功')
await loadUsers(userPage.value)
await loadUserOptions()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '请求失败')
} finally {
editLoading.value = false
}
}
async function removeUser(user: AdminUserItem) {
try {
await ElMessageBox.confirm(`确定删除用户 "${user.username || ''}" 吗?`, '删除确认', {
type: 'warning',
})
} catch {
return
}
try {
const result = await deleteAdminUser(user.id)
if (!result.success) {
ElMessage.error(result.error || '删除失败')
return
}
ElMessage.success('删除成功')
await loadUsers(userPage.value)
await loadUserOptions()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '请求失败')
}
}
function getHistoryPreviewUrls(row: AdminHistoryItem) {
const urls = row.long_image_url ? [row.long_image_url] : []
return urls.concat(row.result_urls || []).slice(0, 3)
}
function handleTabChange(name: string | number) {
if (name === 'history') {
loadHistory(1).catch(() => undefined)
loadUserOptions().catch(() => undefined)
} else {
loadUsers(1).catch(() => undefined)
}
}
onMounted(async () => {
await loadUsers(1)
await loadUserOptions()
})
</script>
<style scoped>
.admin-page {
min-height: 100vh;
padding: 24px;
background: #f5f5f5;
}
.admin-nav {
margin-bottom: 16px;
}
.page-title {
margin: 0 0 20px;
font-size: 24px;
color: #303133;
}
.admin-tabs {
margin-top: 8px;
}
.admin-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 20px;
align-items: start;
}
.form-card,
.table-card {
border-radius: 10px;
}
.card-tip {
margin-bottom: 16px;
color: #666;
font-size: 13px;
line-height: 1.6;
}
.toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.toolbar > * {
min-width: 180px;
}
.row-actions {
display: flex;
gap: 8px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.history-card {
margin-top: 20px;
}
.history-preview {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.preview-image {
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
}
@media (max-width: 1100px) {
.admin-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
<template>
<PageShell theme="light">
<div class="home-page">
<header class="home-header">
<div class="brand">南日AI</div>
<div class="header-actions">
<span>{{ username }}</span>
<el-popover placement="bottom-end" :width="320" trigger="click">
<template #reference>
<el-button circle></el-button>
</template>
<div class="update-panel">
<div class="update-title">软件更新</div>
<div class="update-version">当前版本: v{{ version }}</div>
<el-button type="primary" text :loading="loading" @click="check">检测更新</el-button>
<div class="update-hint">{{ hint }}</div>
<el-button
v-if="downloadVisible"
type="success"
:loading="downloadLoading"
@click="handleUpdate"
>
立即更新
</el-button>
</div>
</el-popover>
<a href="/login" @click.prevent="handleLogout">退出</a>
</div>
</header>
<main class="home-main">
<div class="entrances">
<router-link v-if="isVisible('brand')" class="entrance-card" to="/brand">亚马逊</router-link>
<button v-if="isVisible('wb')" class="entrance-card is-disabled" type="button" @click="showComingSoon">
wildberries
</button>
<router-link v-if="isVisible('image')" class="entrance-card" to="/image">图片</router-link>
</div>
</main>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { logout } from '@/shared/api/auth'
import { useUpdateCheck } from '@/shared/composables/useUpdateCheck'
import { getBootstrapState } from '@/shared/utils/bootstrap'
import { getUserColumnPermissions } from '@/shared/api/admin'
const router = useRouter()
const bootstrap = getBootstrapState()
const username = computed(() => bootstrap.username || '用户')
const userId = computed(() => bootstrap.userId || '')
const visibleKeys = ref<string[]>(['brand', 'wb', 'image'])
const { loading, version, hint, downloadVisible, downloadLoading, check, runUpdate } = useUpdateCheck()
onMounted(async () => {
if (!userId.value) return
try {
const result = await getUserColumnPermissions(userId.value)
if (result.success && Array.isArray(result.items)) {
visibleKeys.value = result.items
.map((item) => (item.column_key || '').toLowerCase())
.filter(Boolean)
}
} catch {
// keep default entrances if permissions cannot be loaded
}
})
const isVisible = (key: string) => visibleKeys.value.includes(key)
const showComingSoon = () => {
ElMessage.info('暂未开通,敬请期待')
}
const handleLogout = async () => {
await logout()
localStorage.removeItem('maixiang_api_key')
window.__BOOTSTRAP__ = {}
await router.replace('/login')
}
const handleUpdate = async () => {
const confirmed = window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')
if (!confirmed) return
await runUpdate()
}
</script>
<style scoped>
.home-page {
min-height: 100vh;
background:
linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
url('/static/bg.jpg') center/cover no-repeat;
}
.home-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.55);
}
.brand {
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.home-main {
min-height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 16px;
}
.entrances {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 24px;
}
.entrance-card {
width: 180px;
height: 110px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--shadow-card);
font-size: 20px;
font-weight: 600;
cursor: pointer;
}
.is-disabled {
color: #909399;
}
.update-panel {
display: grid;
gap: 12px;
}
.update-title {
font-size: 14px;
font-weight: 600;
}
.update-version,
.update-hint {
font-size: 13px;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<PageShell theme="dark">
<div class="image-page">
<header class="top-bar">
<div class="logo-area">
<span class="app-name">南日AI</span>
<router-link to="/home" class="btn-home">返回首页</router-link>
</div>
<div class="nav-tabs">
<div class="nav-tab-group">
<button type="button" class="nav-tab active">图片</button>
</div>
</div>
</header>
<main class="page-body"></main>
</div>
</PageShell>
</template>
<script setup lang="ts">
document.title = '图片 - 南日AI'
</script>
<style scoped>
.image-page {
min-height: 100vh;
background: rgba(13, 13, 13, 1);
color: #e0e0e0;
font-family: "Microsoft YaHei", "Noto Serif SC", "SimSun", "Songti SC", serif;
font-weight: 700;
}
.top-bar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #2a2a2a;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
}
.app-name {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.btn-home {
font-size: 13px;
color: #999;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.btn-home:hover {
color: #fff;
background: #2a2a2a;
}
.nav-tabs {
display: flex;
align-items: center;
}
.nav-tab-group {
display: flex;
align-items: center;
background: rgba(77, 72, 72, 0.99);
border-radius: 10px;
}
.nav-tab {
padding: 8px 14px;
font-size: 13px;
color: #3498db;
background: rgba(52, 152, 219, 0.2);
border: none;
border-radius: 6px;
}
.page-body {
height: calc(100vh - 56px);
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<PageShell theme="light">
<div class="login-page">
<div class="login-header">南日AI</div>
<el-card class="login-card" shadow="never">
<template #header>
<div class="login-title">登录</div>
</template>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
class="login-alert"
/>
<el-form label-width="72px" @submit.prevent="handleSubmit">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" autofocus />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入密码"
@keydown.enter="handleSubmit"
/>
</el-form-item>
<el-button type="primary" :loading="submitting" class="login-button" @click="handleSubmit">
登录
</el-button>
</el-form>
</el-card>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { checkAuth, login } from '@/shared/api/auth'
import { getBootstrapState } from '@/shared/utils/bootstrap'
const router = useRouter()
const bootstrap = getBootstrapState()
const submitting = ref(false)
const errorMessage = ref(bootstrap.error || '')
const form = reactive({
username: '',
password: '',
})
onMounted(async () => {
try {
const result = await checkAuth()
if (!result.logged_in) return
const target = result.redirect || '/home'
if (target.startsWith('/')) {
await router.push(target)
} else {
window.location.href = target
}
} catch {
// ignore auth check failures during bootstrap
}
})
const handleSubmit = async () => {
if (!form.username || !form.password) {
errorMessage.value = '请输入用户名和密码'
return
}
submitting.value = true
errorMessage.value = ''
try {
const result = await login(form.username, form.password)
if (result.success) {
const target = result.redirect || '/home'
if (target.startsWith('/')) {
await router.push(target)
} else {
window.location.href = target
}
return
}
errorMessage.value = result.error || '登录失败'
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
background: linear-gradient(180deg, #dce8f1 0%, #eef4f8 100%);
}
.login-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
padding: 0 24px;
background: rgba(255, 255, 255, 0.6);
color: #303133;
font-size: 18px;
font-weight: 600;
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
box-shadow: var(--shadow-card);
}
.login-title {
text-align: center;
font-size: 24px;
font-weight: 600;
}
.login-alert {
margin-bottom: 16px;
}
.login-button {
width: 100%;
}
</style>

View File

@@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/home',
},
{
path: '/login',
component: () => import('@/pages/login/index.vue'),
meta: { title: '登录 - 南日AI' },
},
{
path: '/home',
component: () => import('@/pages/home/index.vue'),
meta: { title: '首页 - 南日AI' },
},
{
path: '/brand',
component: () => import('@/pages/brand/index.vue'),
meta: { title: '亚马逊 - 南日AI' },
},
{
path: '/admin',
component: () => import('@/pages/admin/index.vue'),
meta: { title: '管理后台 - 南日AI' },
},
{
path: '/image',
component: () => import('@/pages/image/index.vue'),
meta: { title: '图片 - 南日AI' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.afterEach((to) => {
if (to.meta.title) {
document.title = String(to.meta.title)
}
})
export default router

View File

@@ -0,0 +1,96 @@
import { requestDeleteJson, requestGetJson, requestPostJson, requestPutJson } from '@/shared/api/http'
export interface AdminUserItem {
id: number
username?: string
role?: 'super_admin' | 'admin' | 'normal' | string
creator_username?: string
created_at?: string
}
export interface AdminSimpleUser {
id: number
username: string
}
export interface AdminUsersResponse {
success: boolean
current_user_role?: 'super_admin' | 'admin' | 'normal' | string
admins?: AdminSimpleUser[]
items?: AdminUserItem[]
total?: number
page?: number
page_size?: number
error?: string
}
export interface AdminMutationResponse {
success: boolean
msg?: string
error?: string
}
export interface AdminHistoryItem {
id: number
username?: string
panel_type?: string
created_at?: string
long_image_url?: string | null
result_urls?: string[]
}
export interface AdminHistoryResponse {
success: boolean
items?: AdminHistoryItem[]
total?: number
page?: number
page_size?: number
error?: string
}
export interface AdminColumnPermissionItem {
column_key?: string
}
export interface AdminColumnPermissionResponse {
success: boolean
items?: AdminColumnPermissionItem[]
error?: string
}
export function getAdminUsers(query = '') {
const suffix = query ? `?${query}` : ''
return requestGetJson<AdminUsersResponse>(`/api/admin/users${suffix}`)
}
export function createAdminUser(payload: {
username: string
password: string
role: string
created_by_id?: number
}) {
return requestPostJson<AdminMutationResponse>('/api/admin/user', payload)
}
export function updateAdminUser(
userId: number | string,
payload: {
password?: string
role?: string
},
) {
return requestPutJson<AdminMutationResponse>(`/api/admin/user/${userId}`, payload)
}
export function deleteAdminUser(userId: number | string) {
return requestDeleteJson<AdminMutationResponse>(`/api/admin/user/${userId}`)
}
export function getAdminHistory(query = '') {
const suffix = query ? `?${query}` : ''
return requestGetJson<AdminHistoryResponse>(`/api/admin/history${suffix}`)
}
export function getUserColumnPermissions(userId: string | number) {
return requestGetJson<AdminColumnPermissionResponse>(`/api/admin/user/${userId}/column-permissions`)
}

View File

@@ -0,0 +1,38 @@
import { requestGetJson, requestPostJson } from '@/shared/api/http'
export interface LoginResponse {
success: boolean
redirect?: string
error?: string
}
export interface AuthCheckResponse {
logged_in: boolean
redirect?: string
}
export async function checkAuth() {
return requestGetJson<AuthCheckResponse>('/api/auth/check')
}
export async function login(username: string, password: string) {
const formData = new FormData()
formData.set('username', username)
formData.set('password', password)
return requestPostJson<LoginResponse>('/login', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
export async function logout() {
try {
await requestGetJson('/logout', {
validateStatus: () => true,
})
} catch {
// logout should still continue on the client even if backend responds abnormally
}
}

View File

@@ -0,0 +1,88 @@
import { requestDeleteJson, requestGetJson, requestPostJson } from '@/shared/api/http'
export interface BrandExpandFolderResponse {
success: boolean
paths?: string[]
error?: string
}
export interface BrandTaskResultPaths {
zip_url?: string
}
export interface BrandTaskItem {
id: number | string
status?: 'pending' | 'running' | 'success' | 'failed' | 'cancelled' | string
desc?: string
file_paths?: string[]
created_at?: string
progress_total?: number
progress_current?: number
result_paths?: BrandTaskResultPaths
}
export interface BrandTaskListResponse {
success: boolean
items?: BrandTaskItem[]
error?: string
}
export interface BrandTaskDetailResponse {
success: boolean
task?: BrandTaskItem
error?: string
}
export interface BrandTaskMutationResponse {
success: boolean
task_id?: string
error?: string
}
export function getBrandTaskEventsUrl(taskId: string | number) {
return `/api/brand/tasks/${taskId}/events`
}
export function getBrandTaskDownloadUrl(taskId: string | number) {
return `/api/brand/download/${taskId}`
}
export function getBrandTemplateXlsxUrl() {
return '/static/品牌文档格式_模板.xlsx'
}
export function getBrandTemplateZipUrl() {
return '/static/模板2-以文件夹方式上传.zip'
}
export function expandBrandFolder(folder: string) {
return requestPostJson<BrandExpandFolderResponse>('/api/brand/expand-folder', { folder })
}
export function runBrandNow(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>('/api/brand/run', { paths, strategy })
}
export function createBrandTask(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>('/api/brand/tasks', { paths, strategy })
}
export function getBrandTasks() {
return requestGetJson<BrandTaskListResponse>('/api/brand/tasks')
}
export function getBrandTask(taskId: string | number) {
return requestGetJson<BrandTaskDetailResponse>(`/api/brand/tasks/${taskId}`)
}
export function cancelBrandTask(taskId: string | number) {
return requestPostJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}/cancel`)
}
export function deleteBrandTask(taskId: string | number) {
return requestDeleteJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}`)
}
export function createBrandTaskEvents(taskId: string | number) {
return new EventSource(getBrandTaskEventsUrl(taskId))
}

View File

@@ -0,0 +1,107 @@
import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
export interface ApiSuccess<T> {
success: true
msg?: string
data?: T
}
export interface ApiFailure {
success: false
error?: string
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure
export type RequestOptions<D = unknown> = AxiosRequestConfig<D>
function extractErrorMessage(error: unknown) {
if (error instanceof AxiosError) {
const data = error.response?.data
if (data && typeof data === 'object' && 'error' in data) {
return String(data.error)
}
if (typeof data === 'string' && data.trim()) {
return data
}
return error.message || '请求失败'
}
return error instanceof Error ? error.message : '请求失败'
}
function createHttpClient(): AxiosInstance {
const instance = axios.create({
withCredentials: true,
timeout: 30000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
})
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
return config
})
instance.interceptors.response.use(
(response: AxiosResponse) => response,
(error: unknown) => Promise.reject(new Error(extractErrorMessage(error))),
)
return instance
}
export const http = createHttpClient()
export async function request<T = unknown, D = unknown>(config: RequestOptions<D>): Promise<T> {
const response = await http.request<T, AxiosResponse<T>, D>(config)
return response.data
}
export function get<T = unknown>(url: string, config?: RequestOptions) {
return request<T>({
url,
method: 'GET',
...config,
})
}
export function post<T = unknown, D = unknown>(url: string, data?: D, config?: RequestOptions<D>) {
return request<T, D>({
url,
method: 'POST',
data,
...config,
})
}
export function put<T = unknown, D = unknown>(url: string, data?: D, config?: RequestOptions<D>) {
return request<T, D>({
url,
method: 'PUT',
data,
...config,
})
}
export function del<T = unknown>(url: string, config?: RequestOptions) {
return request<T>({
url,
method: 'DELETE',
...config,
})
}
export const requestJson = request
export const requestGetJson = get
export const requestPostJson = post
export const requestPutJson = put
export const requestDeleteJson = del

View File

@@ -0,0 +1,22 @@
import { requestGetJson, requestPostJson } from '@/shared/api/http'
export interface VersionResponse {
version?: string
has_update?: boolean
latest_version?: string
desc?: string
file_url?: string
}
export interface UpdateStartResponse {
success: boolean
error?: string
}
export function fetchVersion() {
return requestGetJson<VersionResponse>('/api/version')
}
export function startUpdate(fileUrl: string) {
return requestPostJson<UpdateStartResponse>('/api/update/do', { file_url: fileUrl })
}

View File

@@ -0,0 +1,24 @@
export interface PywebviewApi {
select_brand_xlsx_files?: () => Promise<string[]>
select_brand_folder?: () => Promise<string | null>
select_folder?: () => Promise<string | null>
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }>
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }>
}
declare global {
interface Window {
pywebview?: {
api?: PywebviewApi
}
}
}
export function getPywebviewApi() {
return window.pywebview?.api
}
export function hasPywebview() {
return Boolean(getPywebviewApi())
}

View File

@@ -0,0 +1,71 @@
import { ref } from 'vue'
import { fetchVersion, startUpdate } from '@/shared/api/update'
export function useUpdateCheck() {
const loading = ref(false)
const version = ref('1.0.0')
const hint = ref('')
const downloadVisible = ref(false)
const downloadLoading = ref(false)
const fileUrl = ref('')
const check = async () => {
loading.value = true
hint.value = '正在检测更新...'
downloadVisible.value = false
try {
const result = await fetchVersion()
version.value = (result.version || version.value).replace(/^v/i, '')
if (result.has_update && result.latest_version) {
hint.value = `发现新版本 v${result.latest_version}${result.desc ? `${result.desc}` : ''}`
fileUrl.value = result.file_url || ''
downloadVisible.value = true
return
}
hint.value = '已是最新版本'
} catch (error) {
hint.value = error instanceof Error ? error.message : '检测更新失败'
} finally {
loading.value = false
}
}
const runUpdate = async () => {
if (!fileUrl.value) {
hint.value = '暂无下载地址,请关注官方渠道。'
return false
}
downloadLoading.value = true
hint.value = '正在下载并准备更新,程序将自动退出...'
try {
const result = await startUpdate(fileUrl.value)
if (result.success) {
hint.value = '更新已启动,程序即将退出...'
return true
}
hint.value = result.error || '更新启动失败'
return false
} catch (error) {
hint.value = error instanceof Error ? error.message : '请求更新失败,请重试'
return false
} finally {
downloadLoading.value = false
}
}
return {
loading,
version,
hint,
downloadVisible,
downloadLoading,
check,
runUpdate,
}
}

View File

@@ -0,0 +1,16 @@
export interface BootstrapState {
username?: string
userId?: string
isAdmin?: boolean
error?: string
}
declare global {
interface Window {
__BOOTSTRAP__?: BootstrapState
}
}
export function getBootstrapState(): BootstrapState {
return window.__BOOTSTRAP__ ?? {}
}

View File

@@ -0,0 +1,51 @@
@import './tokens.css';
@import './reset.css';
body {
background: var(--color-bg-light);
}
html {
scrollbar-color: rgba(123, 135, 148, 0.7) rgba(255, 255, 255, 0.04);
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
}
*::-webkit-scrollbar-thumb {
background: rgba(123, 135, 148, 0.7);
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.9);
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-corner {
background: transparent;
}
.page-shell {
min-height: 100vh;
}
.theme-light {
background: var(--color-bg-light);
color: var(--color-text-primary);
}
.theme-dark {
background: var(--color-bg-dark);
color: var(--color-text-dark);
}

View File

@@ -0,0 +1,27 @@
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
font-family: var(--font-family-base);
color: var(--color-text-primary);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}

View File

@@ -0,0 +1,17 @@
:root {
--font-family-base: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
--color-primary: #409eff;
--color-bg-light: #eef3f8;
--color-bg-dark: #0d0d0d;
--color-surface-light: rgba(255, 255, 255, 0.92);
--color-surface-dark: #1a1a1a;
--color-border-light: #d6e4ef;
--color-border-dark: #2a2a2a;
--color-text-primary: #303133;
--color-text-secondary: #606266;
--color-text-dark: #e5eaf3;
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.08);
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 8px;
}

1
frontend-vue/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />