feat: UOOC/Zhihuishu dual-platform brushing platform
- ASP.NET Core 9 Web API backend with JWT auth, EF Core MySQL - Vue 3 + Vite + Pinia + Ant Design Vue frontend - Multi-platform connection management (UOOC & Zhihuishu) - Video brushing with AES-CBC encryption for Zhihuishu - Multi-task queue with cross-platform parallel execution - Task persistence via MySQL database - Progress tracking with inline catalog enrichment - Mobile-responsive UI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:5088
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>学习进度平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1938
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "uooc-progress-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ConfigProvider } from 'ant-design-vue';
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider
|
||||
:locale="zhCN"
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#c35a2d',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<RouterView />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { uoocLogin } from '../lib/api';
|
||||
import { usePlatformStore } from '../stores/platform';
|
||||
import type { PlatformConnectionDto } from '../types/api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AliyunCaptchaConfig?: { region: string; prefix: string };
|
||||
initAliyunCaptcha?: (config: Record<string, unknown>) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{ platformId: number; connectionName: string }>();
|
||||
const emit = defineEmits<{ success: [connection: PlatformConnectionDto]; close: [] }>();
|
||||
|
||||
const account = ref(''); const password = ref('');
|
||||
const loading = ref(false); const error = ref('');
|
||||
const captchaLoading = ref(false);
|
||||
let scriptLoaded = false;
|
||||
|
||||
onMounted(() => { loadScript(); });
|
||||
|
||||
async function loadScript() {
|
||||
if (scriptLoaded || document.querySelector('script[src*="aliyunCaptcha"]')) { scriptLoaded = true; return; }
|
||||
return new Promise<void>(resolve => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js';
|
||||
s.onload = () => { scriptLoaded = true; resolve(); };
|
||||
s.onerror = () => resolve();
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function waitReady(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let n = 0;
|
||||
const check = () => { if (typeof window.initAliyunCaptcha === 'function') resolve(); else if (++n > 30) reject(new Error('验证码 SDK 超时。')); else setTimeout(check, 200); };
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function triggerCaptcha(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
captchaLoading.value = true;
|
||||
window.AliyunCaptchaConfig = { region: 'cn', prefix: 'dq1y8x' };
|
||||
const m = document.getElementById('uooc-captcha-mount');
|
||||
const t = document.getElementById('uooc-captcha-trigger');
|
||||
if (!m || !t) { captchaLoading.value = false; reject(new Error('组件未就绪。')); return; }
|
||||
m.innerHTML = '';
|
||||
window.initAliyunCaptcha!({
|
||||
SceneId: '137o3jmc', mode: 'popup',
|
||||
element: '#uooc-captcha-mount', button: '#uooc-captcha-trigger',
|
||||
success(p: string) { captchaLoading.value = false; resolve(p); },
|
||||
fail() { captchaLoading.value = false; reject(new Error('验证未通过。')); },
|
||||
getInstance() {},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
});
|
||||
setTimeout(() => t.click(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
const acc = account.value.trim(); const pwd = password.value.trim();
|
||||
if (!acc) { error.value = '请输入手机号。'; return; }
|
||||
if (!pwd) { error.value = '请输入密码。'; return; }
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadScript(); await waitReady();
|
||||
const param = await triggerCaptcha();
|
||||
const r = await uoocLogin({ platformId: props.platformId, connectionName: props.connectionName.trim() || null, account: acc, password: pwd, captchaVerifyParam: param });
|
||||
await usePlatformStore().loadConnections();
|
||||
if (r.connection) { message.success(r.message || '登录成功。'); emit('success', r.connection); }
|
||||
} catch (err) { error.value = err instanceof Error ? err.message : '登录失败。'; }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="true" title="登录 UOOC 学习平台" width="480px" :footer="null" :mask-closable="false" @cancel="emit('close')">
|
||||
<a-alert v-if="error" type="error" :message="error" closable style="margin-bottom: 16px;" @close="error = ''" />
|
||||
|
||||
<a-form layout="vertical" :disabled="loading">
|
||||
<a-form-item label="手机号" required>
|
||||
<a-input v-model:value="account" placeholder="请输入 UOOC 手机号" autocomplete="tel" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="password" placeholder="请输入 UOOC 密码" autocomplete="current-password" />
|
||||
<template #help>
|
||||
密码将通过 HTTPS 加密传输至 UOOC 服务器。
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div id="uooc-captcha-mount" />
|
||||
<button id="uooc-captcha-trigger" type="button" style="display:none;">验证</button>
|
||||
|
||||
<a-tag v-if="captchaLoading" color="blue" style="margin: 8px 0;">请完成弹出的滑块验证...</a-tag>
|
||||
|
||||
<a-flex justify="end" :gap="8" style="margin-top: 16px;">
|
||||
<a-button @click="emit('close')" :disabled="loading">取消</a-button>
|
||||
<a-button type="primary" :loading="loading || captchaLoading" @click="submit">
|
||||
{{ captchaLoading ? '等待验证...' : loading ? '登录中...' : '登录 UOOC 平台' }}
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { zhihuishuLogin } from '../lib/api';
|
||||
import { usePlatformStore } from '../stores/platform';
|
||||
import type { PlatformConnectionDto } from '../types/api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initNECaptcha?: (config: Record<string, unknown>, onload: (instance: any) => void, onerror: (err: unknown) => void) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{ platformId: number; connectionName: string }>();
|
||||
const emit = defineEmits<{ success: [connection: PlatformConnectionDto]; close: [] }>();
|
||||
|
||||
const account = ref(''); const password = ref('');
|
||||
const loading = ref(false); const error = ref('');
|
||||
const captchaReady = ref(false);
|
||||
|
||||
let captchaInstance: any = null;
|
||||
let pendingResolve: ((v: string) => void) | null = null;
|
||||
let pendingReject: ((e: Error) => void) | null = null;
|
||||
|
||||
onMounted(() => { initCaptcha(); });
|
||||
|
||||
onUnmounted(() => {
|
||||
const mount = document.getElementById('zhs-captcha-mount');
|
||||
if (mount) mount.innerHTML = '';
|
||||
});
|
||||
|
||||
async function initCaptcha(): Promise<void> {
|
||||
// Load SDK script
|
||||
if (!document.querySelector('script[src*="cstaticdun.126.net/load.min.js"]')) {
|
||||
await new Promise<void>(resolve => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cstaticdun.126.net/load.min.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => resolve();
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for SDK to be available
|
||||
const ready = await new Promise<boolean>(resolve => {
|
||||
let n = 0;
|
||||
const check = () => {
|
||||
if (typeof window.initNECaptcha === 'function') { resolve(true); return; }
|
||||
if (++n > 50) { resolve(false); return; }
|
||||
setTimeout(check, 200);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
if (!ready) {
|
||||
console.error('[Zhihuishu] 网易易盾 SDK 未就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const mount = document.getElementById('zhs-captcha-mount');
|
||||
if (!mount) return;
|
||||
mount.innerHTML = '';
|
||||
|
||||
window.initNECaptcha!(
|
||||
{
|
||||
captchaId: '75f9f716460a422f89a628f50fd8cc2b',
|
||||
element: '#zhs-captcha-mount',
|
||||
mode: 'popup',
|
||||
onVerify(err: unknown, data: { validate: string }) {
|
||||
if (err) {
|
||||
pendingReject?.(new Error('验证未通过。'));
|
||||
} else {
|
||||
pendingResolve?.(data.validate);
|
||||
}
|
||||
pendingResolve = null;
|
||||
pendingReject = null;
|
||||
},
|
||||
},
|
||||
(instance: any) => {
|
||||
captchaInstance = instance;
|
||||
captchaReady.value = true;
|
||||
console.log('[Zhihuishu] 网易易盾 captcha ready');
|
||||
},
|
||||
(err: unknown) => {
|
||||
console.error('[Zhihuishu] captcha init error:', err);
|
||||
captchaReady.value = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function triggerCaptcha(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!captchaInstance) {
|
||||
reject(new Error('验证码组件未就绪,请刷新页面后重试。'));
|
||||
return;
|
||||
}
|
||||
pendingResolve = resolve;
|
||||
pendingReject = reject;
|
||||
captchaInstance.popUp();
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
const acc = account.value.trim(); const pwd = password.value.trim();
|
||||
if (!acc) { error.value = '请输入手机号。'; return; }
|
||||
if (!pwd) { error.value = '请输入密码。'; return; }
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!captchaInstance) {
|
||||
await initCaptcha();
|
||||
if (!captchaInstance) throw new Error('验证码初始化失败,请刷新页面后重试。');
|
||||
}
|
||||
const captchaValidate = await triggerCaptcha();
|
||||
const r = await zhihuishuLogin({
|
||||
platformId: props.platformId,
|
||||
connectionName: props.connectionName.trim() || null,
|
||||
account: acc,
|
||||
password: pwd,
|
||||
captchaValidate,
|
||||
});
|
||||
await usePlatformStore().loadConnections();
|
||||
if (r.connection) {
|
||||
message.success(r.message || '智慧树登录成功。');
|
||||
emit('success', r.connection);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '登录失败。';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="true" title="登录智慧树平台" width="480px" :footer="null" :mask-closable="false" @cancel="emit('close')">
|
||||
<a-alert
|
||||
v-if="!captchaReady"
|
||||
type="warning"
|
||||
message="正在加载网易易盾验证码组件,请稍候..."
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
|
||||
<a-alert v-if="error" type="error" :message="error" closable style="margin-bottom: 16px;" @close="error = ''" />
|
||||
|
||||
<a-form layout="vertical" :disabled="loading">
|
||||
<a-form-item label="手机号" required>
|
||||
<a-input v-model:value="account" placeholder="请输入智慧树绑定的手机号" autocomplete="tel" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="password" placeholder="请输入智慧树密码" autocomplete="current-password" />
|
||||
<template #help>
|
||||
密码将通过加密传输至智慧树服务器,系统不会存储明文密码。
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div id="zhs-captcha-mount" style="margin: 8px 0; min-height: 40px;" />
|
||||
|
||||
<a-flex justify="end" :gap="8" style="margin-top: 16px;">
|
||||
<a-button @click="emit('close')" :disabled="loading">取消</a-button>
|
||||
<a-button type="primary" :loading="loading" :disabled="!captchaReady" @click="submit">
|
||||
{{ loading ? '登录中...' : '登录智慧树' }}
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
</template>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
BookOutlined, CloudOutlined, DashboardOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
SettingOutlined, TeamOutlined, KeyOutlined, AppstoreOutlined,
|
||||
UserOutlined, LogoutOutlined, LockOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const collapsed = ref(false);
|
||||
const currentTitle = computed(() => typeof route.meta.title === 'string' ? route.meta.title : '学习平台后台');
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const name = typeof route.name === 'string' ? route.name : '';
|
||||
return [name];
|
||||
});
|
||||
|
||||
// Build menu items with icons
|
||||
function icon(component: any) { return () => h(component); }
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items: any[] = [
|
||||
{ key: 'courses', label: '刷课页', icon: icon(BookOutlined) },
|
||||
{ key: 'progress', label: '查询进度', icon: icon(DashboardOutlined) },
|
||||
];
|
||||
if (auth.isAdmin) {
|
||||
items.push(
|
||||
{ type: 'divider' },
|
||||
{ key: 'grp-admin', type: 'group', label: '管理' },
|
||||
{ key: 'admin-users', label: '用户管理', icon: icon(TeamOutlined) },
|
||||
{ key: 'admin-invites', label: '邀请码管理', icon: icon(KeyOutlined) },
|
||||
{ key: 'admin-settings', label: '系统设置', icon: icon(SettingOutlined) },
|
||||
{ key: 'admin-platforms', label: '平台管理', icon: icon(AppstoreOutlined) },
|
||||
{ key: 'admin-tasks', label: '任务监控', icon: icon(DashboardOutlined) },
|
||||
{ key: 'admin-nodes', label: '节点管理', icon: icon(CloudOutlined) },
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function onMenuClick({ key }: { key: string }) {
|
||||
if (!key.startsWith('grp-')) router.push({ name: key });
|
||||
}
|
||||
|
||||
const dropdownItems = [
|
||||
{ key: 'profile', label: '个人信息', icon: icon(UserOutlined) },
|
||||
{ key: 'profile/password', label: '修改密码', icon: icon(LockOutlined) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'logout', label: '退出登录', icon: icon(LogoutOutlined), danger: true },
|
||||
];
|
||||
|
||||
async function onDropdownClick({ key }: { key: string }) {
|
||||
if (key === 'logout') { auth.logout(); await router.replace('/login'); }
|
||||
else router.push(`/${key}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh;">
|
||||
<!-- Dark sidebar -->
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
collapsible
|
||||
breakpoint="lg"
|
||||
theme="dark"
|
||||
width="240"
|
||||
>
|
||||
<!-- Logo area -->
|
||||
<a-flex align="center" style="height: 64px; padding: 0 24px;">
|
||||
<a-typography-title
|
||||
v-if="!collapsed"
|
||||
:level="4"
|
||||
style="color: #fff; margin: 0; white-space: nowrap;"
|
||||
>
|
||||
{{ auth.systemName }}
|
||||
</a-typography-title>
|
||||
</a-flex>
|
||||
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:items="menuItems"
|
||||
@click="onMenuClick"
|
||||
/>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<!-- Header -->
|
||||
<a-layout-header style="background: #fff; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 4px rgba(0,0,0,0.08); z-index: 1;">
|
||||
<a-space :size="8">
|
||||
<a-button
|
||||
type="text"
|
||||
:icon="collapsed ? h(MenuUnfoldOutlined) : h(MenuFoldOutlined)"
|
||||
@click="collapsed = !collapsed"
|
||||
/>
|
||||
<a-breadcrumb class="header-breadcrumb">
|
||||
<a-breadcrumb-item>首页</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ currentTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
|
||||
<a-space size="middle">
|
||||
<a-dropdown>
|
||||
<a-space style="cursor: pointer;">
|
||||
<a-avatar size="small" style="background-color: #c35a2d;">
|
||||
{{ auth.displayInitial }}
|
||||
</a-avatar>
|
||||
<span class="user-name">
|
||||
<a-typography-text strong>{{ auth.currentUser?.displayName }}</a-typography-text>
|
||||
</span>
|
||||
</a-space>
|
||||
<template #overlay>
|
||||
<a-menu :items="dropdownItems" @click="onDropdownClick" />
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- Content -->
|
||||
<a-layout-content class="app-content">
|
||||
<RouterView />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-content {
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.app-content {
|
||||
padding: 12px;
|
||||
}
|
||||
.header-breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
import { readText, storageKeys } from './storage';
|
||||
import type {
|
||||
AuthTokenResponse,
|
||||
AuthUserDto,
|
||||
BrushStatusDto,
|
||||
CatalogResponse,
|
||||
ChallengeSessionDto,
|
||||
ChangePasswordRequest,
|
||||
CourseOptionsResponse,
|
||||
CourseProgressResponse,
|
||||
UnitsResponse,
|
||||
CreateInviteCodeRequest,
|
||||
InviteCodeDto,
|
||||
LoginRequest,
|
||||
PlatformConnectionDto,
|
||||
PlatformCourseQueryRequest,
|
||||
PlatformDefinitionDto,
|
||||
PlatformLoginStartRequest,
|
||||
PlatformLoginStartResponse,
|
||||
PlatformReloginRequest,
|
||||
PlatformSchemaDto,
|
||||
PlatformStatusPatchRequest,
|
||||
PlatformSummaryDto,
|
||||
ProblemDetails,
|
||||
PublicAuthConfigResponse,
|
||||
RegisterRequest,
|
||||
SavePlatformDefinitionRequest,
|
||||
SystemSettingDto,
|
||||
UpdateInviteCodeRequest,
|
||||
UpdateSystemSettingRequest,
|
||||
UpdateUserRequest,
|
||||
UoocLoginRequest,
|
||||
UoocLoginResponse,
|
||||
ZhihuishuLoginRequest,
|
||||
ZhihuishuLoginResponse,
|
||||
} from '../types/api';
|
||||
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
type AuthErrorKind = 'system' | 'platform' | 'unknown';
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly kind: AuthErrorKind = 'unknown',
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers();
|
||||
|
||||
if (!options.skipAuth) {
|
||||
const accessToken = readText(storageKeys.accessToken);
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (options.body !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const problem = await readProblemDetails(response);
|
||||
const authKind = (response.headers.get('X-Auth-Error') ?? 'unknown') as AuthErrorKind;
|
||||
throw new ApiError(
|
||||
problem.detail || problem.title || `Request failed with status ${response.status}.`,
|
||||
response.status,
|
||||
authKind,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function readProblemDetails(response: Response): Promise<ProblemDetails> {
|
||||
try {
|
||||
return (await response.json()) as ProblemDetails;
|
||||
} catch {
|
||||
return {
|
||||
title: response.statusText,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthConfig(): Promise<PublicAuthConfigResponse> {
|
||||
return requestJson('/api/public/auth-config', { skipAuth: true });
|
||||
}
|
||||
|
||||
export function registerUser(payload: RegisterRequest): Promise<AuthTokenResponse> {
|
||||
return requestJson('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailCode(email: string): Promise<void> {
|
||||
return requestJson('/api/auth/send-email-code', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function loginUser(payload: LoginRequest): Promise<AuthTokenResponse> {
|
||||
return requestJson('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentUser(): Promise<AuthUserDto> {
|
||||
return requestJson('/api/auth/me');
|
||||
}
|
||||
|
||||
export function changePassword(payload: ChangePasswordRequest): Promise<void> {
|
||||
return requestJson('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUsers(): Promise<AuthUserDto[]> {
|
||||
return requestJson('/api/admin/users');
|
||||
}
|
||||
|
||||
export function updateAdminUser(userId: number, payload: UpdateUserRequest): Promise<AuthUserDto> {
|
||||
return requestJson(`/api/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getInviteCodes(): Promise<InviteCodeDto[]> {
|
||||
return requestJson('/api/admin/invites');
|
||||
}
|
||||
|
||||
export function createInviteCode(payload: CreateInviteCodeRequest): Promise<InviteCodeDto> {
|
||||
return requestJson('/api/admin/invites', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInviteCode(inviteId: number, payload: UpdateInviteCodeRequest): Promise<InviteCodeDto> {
|
||||
return requestJson(`/api/admin/invites/${inviteId}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSystemSettings(): Promise<SystemSettingDto> {
|
||||
return requestJson('/api/admin/settings');
|
||||
}
|
||||
|
||||
export function updateSystemSettings(payload: UpdateSystemSettingRequest): Promise<SystemSettingDto> {
|
||||
return requestJson('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminPlatforms(): Promise<PlatformSummaryDto[]> {
|
||||
return requestJson('/api/admin/platforms');
|
||||
}
|
||||
|
||||
export function createAdminPlatform(payload: SavePlatformDefinitionRequest): Promise<PlatformDefinitionDto> {
|
||||
return requestJson('/api/admin/platforms', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminPlatform(platformId: number): Promise<PlatformDefinitionDto> {
|
||||
return requestJson(`/api/admin/platforms/${platformId}`);
|
||||
}
|
||||
|
||||
export function updateAdminPlatform(platformId: number, payload: SavePlatformDefinitionRequest): Promise<PlatformDefinitionDto> {
|
||||
return requestJson(`/api/admin/platforms/${platformId}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminPlatformStatus(platformId: number, payload: PlatformStatusPatchRequest): Promise<PlatformDefinitionDto> {
|
||||
return requestJson(`/api/admin/platforms/${platformId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneAdminPlatform(platformId: number): Promise<PlatformDefinitionDto> {
|
||||
return requestJson(`/api/admin/platforms/${platformId}/clone`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlatforms(): Promise<PlatformSummaryDto[]> {
|
||||
return requestJson('/api/platforms');
|
||||
}
|
||||
|
||||
export function getPlatformLoginSchema(platformId: number): Promise<PlatformSchemaDto> {
|
||||
return requestJson(`/api/platforms/${platformId}/schemas/login`);
|
||||
}
|
||||
|
||||
export function getPlatformCourseQuerySchema(platformId: number): Promise<PlatformSchemaDto> {
|
||||
return requestJson(`/api/platforms/${platformId}/schemas/course-query`);
|
||||
}
|
||||
|
||||
export function getPlatformConnections(): Promise<PlatformConnectionDto[]> {
|
||||
return requestJson('/api/platform-connections');
|
||||
}
|
||||
|
||||
export function createPlatformConnection(payload: PlatformLoginStartRequest): Promise<PlatformLoginStartResponse> {
|
||||
return requestJson('/api/platform-connections', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function reloginPlatformConnection(connectionId: number, payload: PlatformReloginRequest): Promise<PlatformLoginStartResponse> {
|
||||
return requestJson(`/api/platform-connections/${connectionId}/relogin`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function uoocLogin(payload: UoocLoginRequest): Promise<UoocLoginResponse> {
|
||||
return requestJson('/api/platform-connections/uooc-login', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function zhihuishuLogin(payload: ZhihuishuLoginRequest): Promise<ZhihuishuLoginResponse> {
|
||||
return requestJson('/api/platform-connections/zhihuishu-login', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function activatePlatformConnection(connectionId: number): Promise<void> {
|
||||
return requestJson(`/api/platform-connections/${connectionId}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePlatformConnection(connectionId: number): Promise<void> {
|
||||
return requestJson(`/api/platform-connections/${connectionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlatformChallenge(challengeSessionId: string): Promise<ChallengeSessionDto> {
|
||||
return requestJson(`/api/platform-challenges/${challengeSessionId}`);
|
||||
}
|
||||
|
||||
export function queryPlatformCourses(connectionId: number, payload: PlatformCourseQueryRequest): Promise<CourseOptionsResponse> {
|
||||
return requestJson(`/api/platform-connections/${connectionId}/courses/query`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlatformCatalog(connectionId: number, courseId: string): Promise<CatalogResponse> {
|
||||
const search = new URLSearchParams({ courseId });
|
||||
return requestJson(`/api/platform-connections/${connectionId}/catalog?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function getPlatformProgress(connectionId: number, courseId: string): Promise<CourseProgressResponse> {
|
||||
const search = new URLSearchParams({ courseId });
|
||||
return requestJson(`/api/platform-connections/${connectionId}/progress?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function getSectionUnits(
|
||||
connectionId: number,
|
||||
courseId: string,
|
||||
chapterId: string,
|
||||
sectionId: string,
|
||||
): Promise<UnitsResponse> {
|
||||
const search = new URLSearchParams({ courseId, chapterId, sectionId });
|
||||
return requestJson(`/api/platform-connections/${connectionId}/units?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function startBrush(payload: StartBrushRequest): Promise<BrushStatusDto> {
|
||||
return requestJson('/api/brush/start', { method: 'POST', body: payload });
|
||||
}
|
||||
|
||||
export function getBrushStatus(): Promise<BrushStatusDto[]> {
|
||||
return requestJson('/api/brush/status');
|
||||
}
|
||||
|
||||
export function stopBrush(): Promise<void> {
|
||||
return requestJson('/api/brush/stop', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function retryBrush(): Promise<BrushStatusDto> {
|
||||
return requestJson('/api/brush/retry', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function stopBrushTask(taskId: string): Promise<void> {
|
||||
return requestJson(`/api/brush/${encodeURIComponent(taskId)}/stop`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function retryBrushTask(taskId: string): Promise<BrushStatusDto> {
|
||||
return requestJson(`/api/brush/${encodeURIComponent(taskId)}/retry`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function deleteBrushTask(taskId: string): Promise<void> {
|
||||
return requestJson(`/api/brush/${encodeURIComponent(taskId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Admin brush APIs
|
||||
export function getAdminBrushTasks(): Promise<AdminBrushTaskDto[]> {
|
||||
return requestJson('/api/admin/brush/tasks');
|
||||
}
|
||||
|
||||
export function adminStopBrushTask(userId: number): Promise<void> {
|
||||
return requestJson(`/api/admin/brush/stop/${userId}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function adminStopAllBrushTasks(): Promise<void> {
|
||||
return requestJson('/api/admin/brush/stop-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getAdminBrushConfig(): Promise<{ pauseNewTasks: boolean }> {
|
||||
return requestJson('/api/admin/brush/config');
|
||||
}
|
||||
|
||||
export function updateAdminBrushConfig(config: { pauseNewTasks: boolean }): Promise<{ pauseNewTasks: boolean }> {
|
||||
return requestJson('/api/admin/brush/config', { method: 'PUT', body: config });
|
||||
}
|
||||
|
||||
export interface AdminBrushTaskDto {
|
||||
userId: number;
|
||||
status: string;
|
||||
totalVideos: number;
|
||||
completedVideos: number;
|
||||
currentChapterName: string;
|
||||
currentSectionName: string;
|
||||
currentVideoTitle: string;
|
||||
currentVideoPos: number;
|
||||
currentVideoLength: number;
|
||||
lastError: string | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
// Node management
|
||||
export function getNodes(): Promise<any[]> { return requestJson('/api/admin/nodes'); }
|
||||
export function generateNodeToken(): Promise<{ token: string }> { return requestJson('/api/admin/nodes/token', { method: 'POST' }); }
|
||||
export function deleteNode(nodeId: number): Promise<void> { return requestJson(`/api/admin/nodes/${nodeId}`, { method: 'DELETE' }); }
|
||||
export function getNodeTasks(): Promise<any[]> { return requestJson('/api/admin/nodes/tasks'); }
|
||||
export function cancelNodeTask(taskId: number): Promise<void> { return requestJson(`/api/admin/nodes/tasks/${taskId}/cancel`, { method: 'POST' }); }
|
||||
|
||||
export interface StartBrushRequest {
|
||||
courseId: string;
|
||||
chapters: ChapterBrushInput[];
|
||||
}
|
||||
|
||||
export interface ChapterBrushInput {
|
||||
chapterId: string;
|
||||
chapterName: string;
|
||||
sections: SectionBrushInput[];
|
||||
}
|
||||
|
||||
export interface SectionBrushInput {
|
||||
sectionId: string;
|
||||
sectionName: string;
|
||||
videos: VideoBrushInput[];
|
||||
}
|
||||
|
||||
export interface VideoBrushInput {
|
||||
resourceId: string;
|
||||
title: string;
|
||||
sectionCatalogId: string;
|
||||
cdnUrl: string;
|
||||
videoLength: number;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
const rootKey = 'uooc-progress';
|
||||
|
||||
export const storageKeys = {
|
||||
accessToken: `${rootKey}:access-token`,
|
||||
selectedPlatformId: (userId: number) => `${rootKey}:user:${userId}:selected-platform-id`,
|
||||
selectedCourseIds: (userId: number) => `${rootKey}:user:${userId}:selected-course-map`,
|
||||
courseOptions: (userId: number) => `${rootKey}:user:${userId}:course-options`,
|
||||
progressSnapshots: (userId: number) => `${rootKey}:user:${userId}:progress-snapshots`,
|
||||
};
|
||||
|
||||
export function readText(key: string, fallback = ''): string {
|
||||
if (!isBrowser) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(key) ?? fallback;
|
||||
}
|
||||
|
||||
export function writeText(key: string, value: string): void {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function removeKey(key: string): void {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export function readJson<T>(key: string, fallback: T): T {
|
||||
if (!isBrowser) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson<T>(key: string, value: T): void {
|
||||
if (!isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import Antd from 'ant-design-vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(Antd);
|
||||
|
||||
const authStore = useAuthStore(pinia);
|
||||
await authStore.bootstrap();
|
||||
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import AppShellLayout from '../layouts/AppShellLayout.vue';
|
||||
import AdminInvitesView from '../views/admin/AdminInvitesView.vue';
|
||||
import AdminPlatformsView from '../views/admin/AdminPlatformsView.vue';
|
||||
import AdminTasksView from '../views/admin/AdminTasksView.vue';
|
||||
import AdminNodesView from '../views/admin/AdminNodesView.vue';
|
||||
import AdminSettingsView from '../views/admin/AdminSettingsView.vue';
|
||||
import AdminUsersView from '../views/admin/AdminUsersView.vue';
|
||||
import ChangePasswordView from '../views/ChangePasswordView.vue';
|
||||
import CourseSelectionView from '../views/CourseSelectionView.vue';
|
||||
import ForbiddenView from '../views/ForbiddenView.vue';
|
||||
import PlatformConnectionsView from '../views/PlatformConnectionsView.vue';
|
||||
import ProfileView from '../views/ProfileView.vue';
|
||||
import ProgressView from '../views/ProgressView.vue';
|
||||
import LoginView from '../views/auth/LoginView.vue';
|
||||
import RegisterView from '../views/auth/RegisterView.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/courses',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
publicOnly: true,
|
||||
title: '系统登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: RegisterView,
|
||||
meta: {
|
||||
publicOnly: true,
|
||||
title: '注册账号',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppShellLayout,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'courses',
|
||||
name: 'courses',
|
||||
component: CourseSelectionView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '刷课页',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'progress',
|
||||
name: 'progress',
|
||||
component: ProgressView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '查询进度',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'platform-connections',
|
||||
name: 'platform-connections',
|
||||
component: PlatformConnectionsView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '平台连接',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
component: ProfileView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '个人信息',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'profile/password',
|
||||
name: 'profile-password',
|
||||
component: ChangePasswordView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '修改密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
name: 'admin-users',
|
||||
component: AdminUsersView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '用户管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/invites',
|
||||
name: 'admin-invites',
|
||||
component: AdminInvitesView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '邀请码管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/settings',
|
||||
name: 'admin-settings',
|
||||
component: AdminSettingsView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '系统设置',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/platforms',
|
||||
name: 'admin-platforms',
|
||||
component: AdminPlatformsView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '平台管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/tasks',
|
||||
name: 'admin-tasks',
|
||||
component: AdminTasksView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '任务监控',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'admin/nodes',
|
||||
name: 'admin-nodes',
|
||||
component: AdminNodesView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: '节点管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'forbidden',
|
||||
name: 'forbidden',
|
||||
component: ForbiddenView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '无权访问',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (!authStore.isReady) {
|
||||
await authStore.bootstrap();
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (to.meta.publicOnly && authStore.isAuthenticated) {
|
||||
return {
|
||||
name: 'courses',
|
||||
};
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
return {
|
||||
name: 'forbidden',
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { ApiError, changePassword, getAuthConfig, getCurrentUser, loginUser, registerUser } from '../lib/api';
|
||||
import { readText, removeKey, storageKeys, writeText } from '../lib/storage';
|
||||
import type { AuthTokenResponse, AuthUserDto, PublicAuthConfigResponse, RegisterRequest } from '../types/api';
|
||||
import { useCoursesStore } from './courses';
|
||||
import { usePlatformStore } from './platform';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string;
|
||||
currentUser: AuthUserDto | null;
|
||||
authConfig: PublicAuthConfigResponse | null;
|
||||
isReady: boolean;
|
||||
isBootstrapping: boolean;
|
||||
authError: string;
|
||||
}
|
||||
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
accessToken: readText(storageKeys.accessToken),
|
||||
currentUser: null,
|
||||
authConfig: null,
|
||||
isReady: false,
|
||||
isBootstrapping: false,
|
||||
authError: '',
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated(state): boolean {
|
||||
return Boolean(state.accessToken && state.currentUser);
|
||||
},
|
||||
isAdmin(state): boolean {
|
||||
return state.currentUser?.role === 'admin';
|
||||
},
|
||||
systemName(state): string {
|
||||
return state.authConfig?.systemName ?? 'UOOC Progress';
|
||||
},
|
||||
requireEmailVerification(state): boolean {
|
||||
return state.authConfig?.requireEmailVerification ?? false;
|
||||
},
|
||||
registrationMode(state): 'open' | 'invite_only' {
|
||||
return state.authConfig?.registrationMode ?? 'open';
|
||||
},
|
||||
displayInitial(state): string {
|
||||
return state.currentUser?.displayName?.slice(0, 1).toUpperCase() || 'U';
|
||||
},
|
||||
roleLabel(state): string {
|
||||
return state.currentUser?.role === 'admin' ? '管理员' : '普通用户';
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async bootstrap(): Promise<void> {
|
||||
if (this.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bootstrapPromise) {
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
this.isBootstrapping = true;
|
||||
bootstrapPromise = (async () => {
|
||||
await this.loadAuthConfig();
|
||||
|
||||
const token = readText(storageKeys.accessToken);
|
||||
this.accessToken = token;
|
||||
if (token) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
this.currentUser = user;
|
||||
this.initializeUserScopedState(user.id);
|
||||
} catch (error) {
|
||||
this.handleSystemUnauthorized(error instanceof Error ? error.message : '系统登录已失效。');
|
||||
}
|
||||
}
|
||||
|
||||
this.isReady = true;
|
||||
this.isBootstrapping = false;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
},
|
||||
async loadAuthConfig(): Promise<void> {
|
||||
try {
|
||||
this.authConfig = await getAuthConfig();
|
||||
} catch {
|
||||
this.authConfig = { registrationMode: 'open', systemName: 'UOOC Progress', requireEmailVerification: false };
|
||||
}
|
||||
},
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
this.authError = '';
|
||||
|
||||
try {
|
||||
const response = await loginUser({
|
||||
username: username.trim(),
|
||||
password: password.trim(),
|
||||
});
|
||||
this.applyAuth(response);
|
||||
} catch (error) {
|
||||
this.authError = error instanceof Error ? error.message : '登录失败,请稍后重试。';
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async register(payload: RegisterRequest): Promise<void> {
|
||||
this.authError = '';
|
||||
|
||||
try {
|
||||
const response = await registerUser(payload);
|
||||
this.applyAuth(response);
|
||||
} catch (error) {
|
||||
this.authError = error instanceof Error ? error.message : '注册失败,请稍后重试。';
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async updatePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await changePassword({ currentPassword, newPassword });
|
||||
},
|
||||
applyAuth(response: AuthTokenResponse): void {
|
||||
this.accessToken = response.accessToken;
|
||||
this.currentUser = response.user;
|
||||
this.authError = '';
|
||||
writeText(storageKeys.accessToken, response.accessToken);
|
||||
this.initializeUserScopedState(response.user.id);
|
||||
},
|
||||
initializeUserScopedState(userId: number): void {
|
||||
usePlatformStore().restoreForUser(userId);
|
||||
useCoursesStore().restoreForUser(userId);
|
||||
},
|
||||
logout(): void {
|
||||
this.accessToken = '';
|
||||
this.currentUser = null;
|
||||
this.authError = '';
|
||||
removeKey(storageKeys.accessToken);
|
||||
usePlatformStore().resetInMemory();
|
||||
useCoursesStore().resetInMemory();
|
||||
},
|
||||
handleSystemUnauthorized(message = '系统登录已失效,请重新登录。'): void {
|
||||
this.logout();
|
||||
this.authError = message;
|
||||
},
|
||||
handleApiError(error: unknown): string {
|
||||
if (error instanceof ApiError && error.kind === 'system') {
|
||||
this.handleSystemUnauthorized('系统登录已失效,请重新登录。');
|
||||
return '系统登录已失效,请重新登录。';
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { ApiError, getPlatformProgress, queryPlatformCourses } from '../lib/api';
|
||||
import { readJson, storageKeys, writeJson } from '../lib/storage';
|
||||
import type { CourseOptionDto, CourseOptionsResponse, CourseProgressResponse } from '../types/api';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
interface CourseState {
|
||||
userId: number | null;
|
||||
courseOptionsByConnection: Record<string, CourseOptionDto[]>;
|
||||
selectedCourseByConnection: Record<string, string>;
|
||||
progressSnapshots: Record<string, CourseProgressResponse>;
|
||||
loadingConnections: string[];
|
||||
loadingProgressKeys: string[];
|
||||
errorByConnection: Record<string, string>;
|
||||
messageByConnection: Record<string, string>;
|
||||
}
|
||||
|
||||
export const useCoursesStore = defineStore('courses', {
|
||||
state: (): CourseState => ({
|
||||
userId: null,
|
||||
courseOptionsByConnection: {},
|
||||
selectedCourseByConnection: {},
|
||||
progressSnapshots: {},
|
||||
loadingConnections: [],
|
||||
loadingProgressKeys: [],
|
||||
errorByConnection: {},
|
||||
messageByConnection: {},
|
||||
}),
|
||||
actions: {
|
||||
restoreForUser(userId: number): void {
|
||||
this.userId = userId;
|
||||
this.courseOptionsByConnection = readJson(storageKeys.courseOptions(userId), {});
|
||||
this.selectedCourseByConnection = readJson(storageKeys.selectedCourseIds(userId), {});
|
||||
this.progressSnapshots = readJson(storageKeys.progressSnapshots(userId), {});
|
||||
this.loadingConnections = [];
|
||||
this.loadingProgressKeys = [];
|
||||
this.errorByConnection = {};
|
||||
this.messageByConnection = {};
|
||||
},
|
||||
resetInMemory(): void {
|
||||
this.userId = null;
|
||||
this.courseOptionsByConnection = {};
|
||||
this.selectedCourseByConnection = {};
|
||||
this.progressSnapshots = {};
|
||||
this.loadingConnections = [];
|
||||
this.loadingProgressKeys = [];
|
||||
this.errorByConnection = {};
|
||||
this.messageByConnection = {};
|
||||
},
|
||||
persist(): void {
|
||||
if (!this.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(storageKeys.courseOptions(this.userId), this.courseOptionsByConnection);
|
||||
writeJson(storageKeys.selectedCourseIds(this.userId), this.selectedCourseByConnection);
|
||||
writeJson(storageKeys.progressSnapshots(this.userId), this.progressSnapshots);
|
||||
},
|
||||
getSelectedCourseId(connectionId: number | null | undefined): string {
|
||||
if (!connectionId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.selectedCourseByConnection[String(connectionId)] ?? '';
|
||||
},
|
||||
getCourseOptions(connectionId: number | null | undefined): CourseOptionDto[] {
|
||||
if (!connectionId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.courseOptionsByConnection[String(connectionId)] ?? [];
|
||||
},
|
||||
getProgress(connectionId: number | null | undefined, courseId: string): CourseProgressResponse | null {
|
||||
if (!connectionId || !courseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.progressSnapshots[`${connectionId}:${courseId}`] ?? null;
|
||||
},
|
||||
setSelectedCourse(connectionId: number, courseId: string): void {
|
||||
this.selectedCourseByConnection[String(connectionId)] = courseId;
|
||||
this.persist();
|
||||
},
|
||||
async queryCourses(connectionId: number, fields: Record<string, string | null | undefined>): Promise<CourseOptionsResponse> {
|
||||
const key = String(connectionId);
|
||||
if (!this.loadingConnections.includes(key)) {
|
||||
this.loadingConnections.push(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryPlatformCourses(connectionId, { fields });
|
||||
this.courseOptionsByConnection[key] = response.items;
|
||||
this.messageByConnection[key] = response.message ?? '';
|
||||
delete this.errorByConnection[key];
|
||||
|
||||
if (response.items.length > 0 && !this.selectedCourseByConnection[key]) {
|
||||
this.selectedCourseByConnection[key] = response.items[0].value;
|
||||
}
|
||||
|
||||
this.persist();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.errorByConnection[key] = this.resolveApiError(error, '课程列表加载失败。');
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingConnections = this.loadingConnections.filter((item) => item !== key);
|
||||
}
|
||||
},
|
||||
async refreshProgress(connectionId: number, courseId: string): Promise<CourseProgressResponse> {
|
||||
const key = `${connectionId}:${courseId}`;
|
||||
if (!this.loadingProgressKeys.includes(key)) {
|
||||
this.loadingProgressKeys.push(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPlatformProgress(connectionId, courseId);
|
||||
this.progressSnapshots[key] = response;
|
||||
delete this.errorByConnection[String(connectionId)];
|
||||
this.persist();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.errorByConnection[String(connectionId)] = this.resolveApiError(error, '进度刷新失败。');
|
||||
throw error;
|
||||
} finally {
|
||||
this.loadingProgressKeys = this.loadingProgressKeys.filter((item) => item !== key);
|
||||
}
|
||||
},
|
||||
isLoadingCourses(connectionId: number | null | undefined): boolean {
|
||||
return connectionId ? this.loadingConnections.includes(String(connectionId)) : false;
|
||||
},
|
||||
isLoadingProgress(connectionId: number | null | undefined, courseId: string): boolean {
|
||||
return connectionId ? this.loadingProgressKeys.includes(`${connectionId}:${courseId}`) : false;
|
||||
},
|
||||
resolveApiError(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.kind === 'system') {
|
||||
useAuthStore().handleSystemUnauthorized('系统登录已失效,请重新登录。');
|
||||
return '系统登录已失效,请重新登录。';
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import {
|
||||
activatePlatformConnection,
|
||||
createPlatformConnection,
|
||||
deletePlatformConnection,
|
||||
getPlatformChallenge,
|
||||
getPlatformConnections,
|
||||
getPlatformCourseQuerySchema,
|
||||
getPlatformLoginSchema,
|
||||
getPlatforms,
|
||||
reloginPlatformConnection,
|
||||
} from '../lib/api';
|
||||
import { readText, storageKeys, writeText } from '../lib/storage';
|
||||
import type {
|
||||
ChallengeSessionDto,
|
||||
PlatformConnectionDto,
|
||||
PlatformLoginStartResponse,
|
||||
PlatformSchemaDto,
|
||||
PlatformSummaryDto,
|
||||
} from '../types/api';
|
||||
import { useAuthStore } from './auth';
|
||||
|
||||
interface PlatformState {
|
||||
userId: number | null;
|
||||
platforms: PlatformSummaryDto[];
|
||||
connections: PlatformConnectionDto[];
|
||||
selectedPlatformId: number | null;
|
||||
loginSchemas: Record<string, PlatformSchemaDto>;
|
||||
courseQuerySchemas: Record<string, PlatformSchemaDto>;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
message: string;
|
||||
currentChallenge: ChallengeSessionDto | null;
|
||||
isPollingChallenge: boolean;
|
||||
}
|
||||
|
||||
let challengePollTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
export const usePlatformStore = defineStore('platform', {
|
||||
state: (): PlatformState => ({
|
||||
userId: null,
|
||||
platforms: [],
|
||||
connections: [],
|
||||
selectedPlatformId: null,
|
||||
loginSchemas: {},
|
||||
courseQuerySchemas: {},
|
||||
isLoading: false,
|
||||
error: '',
|
||||
message: '',
|
||||
currentChallenge: null,
|
||||
isPollingChallenge: false,
|
||||
}),
|
||||
getters: {
|
||||
activeConnection(state): PlatformConnectionDto | null {
|
||||
return state.connections.find((item) => item.isActive) ?? null;
|
||||
},
|
||||
selectedPlatform(state): PlatformSummaryDto | null {
|
||||
return state.platforms.find((item) => item.id === state.selectedPlatformId) ?? null;
|
||||
},
|
||||
activeConnectionLabel(): string {
|
||||
if (!this.activeConnection) {
|
||||
return '未连接平台';
|
||||
}
|
||||
|
||||
return this.activeConnection.status === 'connected'
|
||||
? `${this.activeConnection.platformName} 已连接`
|
||||
: `${this.activeConnection.platformName} 连接中`;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
restoreForUser(userId: number): void {
|
||||
this.userId = userId;
|
||||
this.platforms = [];
|
||||
this.connections = [];
|
||||
this.loginSchemas = {};
|
||||
this.courseQuerySchemas = {};
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
this.currentChallenge = null;
|
||||
this.isPollingChallenge = false;
|
||||
|
||||
const savedPlatformId = readText(storageKeys.selectedPlatformId(userId));
|
||||
this.selectedPlatformId = savedPlatformId ? Number(savedPlatformId) : null;
|
||||
void this.bootstrapPlatformContext();
|
||||
},
|
||||
resetInMemory(): void {
|
||||
this.userId = null;
|
||||
this.platforms = [];
|
||||
this.connections = [];
|
||||
this.selectedPlatformId = null;
|
||||
this.loginSchemas = {};
|
||||
this.courseQuerySchemas = {};
|
||||
this.isLoading = false;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
this.currentChallenge = null;
|
||||
this.isPollingChallenge = false;
|
||||
if (challengePollTimer) {
|
||||
window.clearTimeout(challengePollTimer);
|
||||
challengePollTimer = null;
|
||||
}
|
||||
},
|
||||
async bootstrapPlatformContext(): Promise<void> {
|
||||
if (this.isLoading) return; // prevent concurrent calls
|
||||
this.isLoading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
await Promise.all([this.loadPlatforms(), this.loadConnections()]);
|
||||
|
||||
if (!this.selectedPlatformId) {
|
||||
this.selectedPlatformId = this.activeConnection?.platformId ?? this.platforms[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (this.userId && this.selectedPlatformId) {
|
||||
writeText(storageKeys.selectedPlatformId(this.userId), String(this.selectedPlatformId));
|
||||
}
|
||||
|
||||
if (this.selectedPlatformId) {
|
||||
await Promise.all([
|
||||
this.ensureLoginSchema(this.selectedPlatformId),
|
||||
this.ensureCourseQuerySchema(this.selectedPlatformId),
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = this.resolveApiError(error, '平台数据加载失败。');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
async loadPlatforms(): Promise<void> {
|
||||
this.platforms = await getPlatforms();
|
||||
},
|
||||
async loadConnections(): Promise<void> {
|
||||
this.connections = await getPlatformConnections();
|
||||
},
|
||||
selectPlatform(platformId: number): void {
|
||||
this.selectedPlatformId = platformId;
|
||||
if (this.userId) {
|
||||
writeText(storageKeys.selectedPlatformId(this.userId), String(platformId));
|
||||
}
|
||||
|
||||
// Auto-activate a connected connection for this platform if one exists
|
||||
const conn = this.connections.find(c => c.platformId === platformId && c.status === 'connected');
|
||||
if (conn && !conn.isActive) {
|
||||
void this.activateConnection(conn.id);
|
||||
}
|
||||
|
||||
void Promise.all([this.ensureLoginSchema(platformId), this.ensureCourseQuerySchema(platformId)]);
|
||||
},
|
||||
async ensureLoginSchema(platformId: number): Promise<PlatformSchemaDto> {
|
||||
const key = String(platformId);
|
||||
if (!this.loginSchemas[key]) {
|
||||
this.loginSchemas[key] = await getPlatformLoginSchema(platformId);
|
||||
}
|
||||
|
||||
return this.loginSchemas[key];
|
||||
},
|
||||
async ensureCourseQuerySchema(platformId: number): Promise<PlatformSchemaDto> {
|
||||
const key = String(platformId);
|
||||
if (!this.courseQuerySchemas[key]) {
|
||||
this.courseQuerySchemas[key] = await getPlatformCourseQuerySchema(platformId);
|
||||
}
|
||||
|
||||
return this.courseQuerySchemas[key];
|
||||
},
|
||||
async createConnection(
|
||||
platformId: number,
|
||||
connectionName: string,
|
||||
fields: Record<string, string | null | undefined>,
|
||||
): Promise<PlatformLoginStartResponse> {
|
||||
this.error = '';
|
||||
const response = await createPlatformConnection({
|
||||
platformId,
|
||||
connectionName: connectionName.trim() || null,
|
||||
fields,
|
||||
});
|
||||
|
||||
await this.loadConnections();
|
||||
this.message = response.message;
|
||||
if (response.connection?.platformId) {
|
||||
this.selectPlatform(response.connection.platformId);
|
||||
}
|
||||
|
||||
if (response.status === 'challenge_required' && response.challengeSessionId) {
|
||||
await this.startChallengePolling(response.challengeSessionId);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async reloginConnection(
|
||||
connectionId: number,
|
||||
fields: Record<string, string | null | undefined>,
|
||||
): Promise<PlatformLoginStartResponse> {
|
||||
this.error = '';
|
||||
const response = await reloginPlatformConnection(connectionId, { fields });
|
||||
await this.loadConnections();
|
||||
this.message = response.message;
|
||||
|
||||
if (response.status === 'challenge_required' && response.challengeSessionId) {
|
||||
await this.startChallengePolling(response.challengeSessionId);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async activateConnection(connectionId: number): Promise<void> {
|
||||
await activatePlatformConnection(connectionId);
|
||||
await this.loadConnections();
|
||||
},
|
||||
async deleteConnection(connectionId: number): Promise<void> {
|
||||
await deletePlatformConnection(connectionId);
|
||||
await this.loadConnections();
|
||||
},
|
||||
async startChallengePolling(challengeSessionId: string): Promise<void> {
|
||||
if (challengePollTimer) {
|
||||
window.clearTimeout(challengePollTimer);
|
||||
}
|
||||
|
||||
this.isPollingChallenge = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const challenge = await getPlatformChallenge(challengeSessionId);
|
||||
this.currentChallenge = challenge;
|
||||
if (challenge.status === 'pending') {
|
||||
challengePollTimer = window.setTimeout(() => {
|
||||
void poll();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPollingChallenge = false;
|
||||
this.message = challenge.message;
|
||||
await this.loadConnections();
|
||||
challengePollTimer = null;
|
||||
} catch (error) {
|
||||
this.isPollingChallenge = false;
|
||||
this.error = this.resolveApiError(error, '验证状态查询失败。');
|
||||
challengePollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
resolveApiError(error: unknown, fallback: string): string {
|
||||
return useAuthStore().handleApiError(error) || fallback;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
export interface ProblemDetails {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface PublicAuthConfigResponse {
|
||||
registrationMode: 'open' | 'invite_only';
|
||||
systemName: string;
|
||||
requireEmailVerification: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
email?: string;
|
||||
emailCode?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface AuthUserDto {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: 'user' | 'admin';
|
||||
status: 'active' | 'disabled';
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
user: AuthUserDto;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
displayName?: string;
|
||||
role?: 'user' | 'admin';
|
||||
status?: 'active' | 'disabled';
|
||||
}
|
||||
|
||||
export interface InviteCodeDto {
|
||||
id: number;
|
||||
code: string;
|
||||
status: 'active' | 'disabled';
|
||||
maxUses: number;
|
||||
usedCount: number;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
createdByDisplayName: string;
|
||||
}
|
||||
|
||||
export interface CreateInviteCodeRequest {
|
||||
code?: string;
|
||||
maxUses: number;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInviteCodeRequest {
|
||||
status: 'active' | 'disabled';
|
||||
}
|
||||
|
||||
export interface SystemSettingDto {
|
||||
systemName: string;
|
||||
registrationMode: 'open' | 'invite_only';
|
||||
allowMockFallback: boolean;
|
||||
browserChallengeTimeoutSeconds: number;
|
||||
connectionEncryptionVersion: number;
|
||||
defaultPlatformVisibility: string;
|
||||
requireEmailVerification: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpUseSsl: boolean;
|
||||
smtpUsername: string | null;
|
||||
hasSmtpPassword: boolean;
|
||||
smtpFromEmail: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateSystemSettingRequest {
|
||||
systemName: string;
|
||||
registrationMode: 'open' | 'invite_only';
|
||||
allowMockFallback: boolean;
|
||||
browserChallengeTimeoutSeconds: number;
|
||||
connectionEncryptionVersion: number;
|
||||
defaultPlatformVisibility: string;
|
||||
requireEmailVerification: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpUseSsl: boolean;
|
||||
smtpUsername: string | null;
|
||||
smtpPassword: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
}
|
||||
|
||||
export interface SelectOptionDto {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PlatformFieldDefinitionDto {
|
||||
id: number;
|
||||
scope: 'login' | 'course_query';
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'select' | 'textarea' | 'captcha_text' | 'sms_code' | 'email_code' | 'hidden';
|
||||
isRequired: boolean;
|
||||
displayOrder: number;
|
||||
placeholder: string | null;
|
||||
helpText: string | null;
|
||||
defaultValue: string | null;
|
||||
isSensitive: boolean;
|
||||
options: SelectOptionDto[];
|
||||
}
|
||||
|
||||
export interface PlatformCookieMappingDto {
|
||||
name: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface PlatformOutputVariableDto {
|
||||
key: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface CourseOptionMappingDto {
|
||||
itemsPath: string;
|
||||
labelPath: string;
|
||||
valuePath: string;
|
||||
}
|
||||
|
||||
export interface CatalogMappingDto {
|
||||
chaptersPath: string;
|
||||
chapterIdPath: string;
|
||||
chapterNumberPath: string;
|
||||
chapterNamePath: string;
|
||||
chapterFinishedPath: string;
|
||||
chapterLearningPath: string;
|
||||
sectionsPath: string;
|
||||
sectionIdPath: string;
|
||||
sectionNumberPath: string;
|
||||
sectionNamePath: string;
|
||||
sectionFinishedPath: string;
|
||||
sectionLearningPath: string;
|
||||
sectionTaskIdPath: string;
|
||||
}
|
||||
|
||||
export interface UnitMappingDto {
|
||||
itemsPath: string;
|
||||
itemIdPath: string;
|
||||
itemTitlePath: string;
|
||||
itemTypePath: string;
|
||||
itemFinishedPath: string;
|
||||
videoSourcePath: string;
|
||||
videoSourceNamePath: string;
|
||||
videoPositionPath: string;
|
||||
videoLengthPath: string;
|
||||
documentCountPath: string;
|
||||
}
|
||||
|
||||
export interface PlatformWorkflowStepDto {
|
||||
id: number;
|
||||
scope: 'login' | 'course_query' | 'catalog' | 'units' | 'progress';
|
||||
stepKey: string;
|
||||
displayName: string;
|
||||
displayOrder: number;
|
||||
stepType: 'http_request' | 'session_passthrough' | 'browser_challenge';
|
||||
httpMethod: string;
|
||||
urlTemplate: string | null;
|
||||
queryTemplateJson: string | null;
|
||||
headersTemplateJson: string | null;
|
||||
bodyTemplateJson: string | null;
|
||||
contentType: string | null;
|
||||
successPath: string | null;
|
||||
successExpectedValue: string | null;
|
||||
platformUserLabelExpression: string | null;
|
||||
outputCookies: PlatformCookieMappingDto[];
|
||||
outputVariables: PlatformOutputVariableDto[];
|
||||
courseOptionMapping: CourseOptionMappingDto | null;
|
||||
catalogMapping: CatalogMappingDto | null;
|
||||
unitMapping: UnitMappingDto | null;
|
||||
browserSuccessUrlContains: string | null;
|
||||
browserSuccessCookieName: string | null;
|
||||
browserWaitForSelector: string | null;
|
||||
browserTimeoutSeconds: number | null;
|
||||
browserAutomationJson: string | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformSummaryDto {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
status: 'draft' | 'active' | 'disabled';
|
||||
enableBrowserChallenge: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformDefinitionDto {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
status: 'draft' | 'active' | 'disabled';
|
||||
enableBrowserChallenge: boolean;
|
||||
courseQueryStepKey: string | null;
|
||||
supportsCatalog: boolean;
|
||||
supportsUnits: boolean;
|
||||
supportsProgress: boolean;
|
||||
challengeTimeoutSeconds: number;
|
||||
loginFields: PlatformFieldDefinitionDto[];
|
||||
courseQueryFields: PlatformFieldDefinitionDto[];
|
||||
loginSteps: PlatformWorkflowStepDto[];
|
||||
courseQuerySteps: PlatformWorkflowStepDto[];
|
||||
catalogSteps: PlatformWorkflowStepDto[];
|
||||
unitSteps: PlatformWorkflowStepDto[];
|
||||
progressSteps: PlatformWorkflowStepDto[];
|
||||
}
|
||||
|
||||
export interface PlatformSchemaDto {
|
||||
platformId: number;
|
||||
platformName: string;
|
||||
scope: 'login' | 'course_query';
|
||||
fields: PlatformFieldDefinitionDto[];
|
||||
}
|
||||
|
||||
export interface UpsertPlatformFieldDefinitionRequest {
|
||||
id?: number | null;
|
||||
scope: 'login' | 'course_query';
|
||||
key: string;
|
||||
label: string;
|
||||
type: PlatformFieldDefinitionDto['type'];
|
||||
isRequired: boolean;
|
||||
displayOrder: number;
|
||||
placeholder?: string | null;
|
||||
helpText?: string | null;
|
||||
defaultValue?: string | null;
|
||||
isSensitive: boolean;
|
||||
options: SelectOptionDto[];
|
||||
}
|
||||
|
||||
export interface UpsertPlatformWorkflowStepRequest {
|
||||
id?: number | null;
|
||||
scope: PlatformWorkflowStepDto['scope'];
|
||||
stepKey: string;
|
||||
displayName: string;
|
||||
displayOrder: number;
|
||||
stepType: PlatformWorkflowStepDto['stepType'];
|
||||
httpMethod: string;
|
||||
urlTemplate?: string | null;
|
||||
queryTemplateJson?: string | null;
|
||||
headersTemplateJson?: string | null;
|
||||
bodyTemplateJson?: string | null;
|
||||
contentType?: string | null;
|
||||
successPath?: string | null;
|
||||
successExpectedValue?: string | null;
|
||||
platformUserLabelExpression?: string | null;
|
||||
outputCookies: PlatformCookieMappingDto[];
|
||||
outputVariables: PlatformOutputVariableDto[];
|
||||
courseOptionMapping?: CourseOptionMappingDto | null;
|
||||
catalogMapping?: CatalogMappingDto | null;
|
||||
unitMapping?: UnitMappingDto | null;
|
||||
browserSuccessUrlContains?: string | null;
|
||||
browserSuccessCookieName?: string | null;
|
||||
browserWaitForSelector?: string | null;
|
||||
browserTimeoutSeconds?: number | null;
|
||||
browserAutomationJson?: string | null;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SavePlatformDefinitionRequest {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
status: 'draft' | 'active' | 'disabled';
|
||||
enableBrowserChallenge: boolean;
|
||||
courseQueryStepKey?: string | null;
|
||||
supportsCatalog: boolean;
|
||||
supportsUnits: boolean;
|
||||
supportsProgress: boolean;
|
||||
challengeTimeoutSeconds: number;
|
||||
fields: UpsertPlatformFieldDefinitionRequest[];
|
||||
steps: UpsertPlatformWorkflowStepRequest[];
|
||||
}
|
||||
|
||||
export interface PlatformStatusPatchRequest {
|
||||
status: 'draft' | 'active' | 'disabled';
|
||||
}
|
||||
|
||||
export interface PlatformConnectionDto {
|
||||
id: number;
|
||||
platformId: number;
|
||||
platformName: string;
|
||||
platformSlug: string;
|
||||
connectionName: string;
|
||||
platformUserLabel: string | null;
|
||||
status: 'pending' | 'connected' | 'challenge_pending' | 'failed' | 'disabled';
|
||||
isActive: boolean;
|
||||
hasStoredCredentials: boolean;
|
||||
hasChallengePending: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastValidatedAt: string | null;
|
||||
lastSuccessfulLoginAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformLoginStartRequest {
|
||||
platformId: number;
|
||||
connectionName?: string | null;
|
||||
fields: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface PlatformReloginRequest {
|
||||
fields: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface UoocLoginRequest {
|
||||
platformId: number;
|
||||
connectionName?: string | null;
|
||||
account: string;
|
||||
password: string;
|
||||
captchaVerifyParam: string;
|
||||
}
|
||||
|
||||
export interface UoocLoginResponse {
|
||||
status: 'connected';
|
||||
message: string;
|
||||
connection: PlatformConnectionDto | null;
|
||||
}
|
||||
|
||||
export interface ZhihuishuLoginRequest {
|
||||
platformId: number;
|
||||
connectionName?: string | null;
|
||||
account: string;
|
||||
password: string;
|
||||
captchaValidate: string;
|
||||
}
|
||||
|
||||
export interface ZhihuishuLoginResponse {
|
||||
status: 'connected';
|
||||
message: string;
|
||||
connection: PlatformConnectionDto | null;
|
||||
}
|
||||
|
||||
export interface PlatformLoginStartResponse {
|
||||
status: 'connected' | 'challenge_required';
|
||||
message: string;
|
||||
connection: PlatformConnectionDto | null;
|
||||
challengeSessionId: string | null;
|
||||
challengeUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ChallengeSessionDto {
|
||||
id: string;
|
||||
status: 'pending' | 'completed' | 'failed' | 'expired';
|
||||
message: string;
|
||||
challengeUrl: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CourseOptionDto {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PlatformCourseQueryRequest {
|
||||
fields: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface CourseOptionsResponse {
|
||||
connectionId: number;
|
||||
platformName: string;
|
||||
items: CourseOptionDto[];
|
||||
queriedAt: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSectionDto {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
finished: boolean;
|
||||
learning: boolean;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface CatalogChapterDto {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
finished: boolean;
|
||||
learning: boolean;
|
||||
sections: CatalogSectionDto[];
|
||||
}
|
||||
|
||||
export interface CatalogResponse {
|
||||
courseId: string;
|
||||
chapters: CatalogChapterDto[];
|
||||
mock: boolean;
|
||||
source: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface UnitsResponse {
|
||||
courseId: string;
|
||||
chapterId: string;
|
||||
sectionId: string;
|
||||
items: UnitItemDto[];
|
||||
mock: boolean;
|
||||
source: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface BrushStatusDto {
|
||||
taskId: string;
|
||||
platformSlug: string;
|
||||
courseId: string;
|
||||
chaptersSummary: string;
|
||||
status: string;
|
||||
totalVideos: number;
|
||||
completedVideos: number;
|
||||
currentChapterName: string;
|
||||
currentSectionName: string;
|
||||
currentVideoTitle: string;
|
||||
currentVideoPos: number;
|
||||
currentVideoLength: number;
|
||||
lastError: string | null;
|
||||
retryCount: number;
|
||||
queuedCount: number;
|
||||
createdAt: string;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export interface VideoSourceDto {
|
||||
source: string;
|
||||
sourceName: string;
|
||||
}
|
||||
|
||||
export interface UnitItemDto {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
finished: boolean;
|
||||
hasVideo: boolean;
|
||||
videoPosition: number;
|
||||
videoLength: number | null;
|
||||
primarySourceName: string | null;
|
||||
primarySourceUrl: string | null;
|
||||
documentCount: number;
|
||||
videoSources: VideoSourceDto[];
|
||||
}
|
||||
|
||||
export interface ProgressSummaryDto {
|
||||
totalSections: number;
|
||||
completedSections: number;
|
||||
inProgressSections: number;
|
||||
totalResources: number;
|
||||
completedResources: number;
|
||||
sectionCompletionRate: number;
|
||||
resourceCompletionRate: number;
|
||||
}
|
||||
|
||||
export interface SectionProgressDto {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
finished: boolean;
|
||||
learning: boolean;
|
||||
state: 'completed' | 'in-progress' | 'not-started' | 'no-resource';
|
||||
resourceCount: number;
|
||||
completedResourceCount: number;
|
||||
resources: UnitItemDto[];
|
||||
}
|
||||
|
||||
export interface ChapterProgressDto {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
finished: boolean;
|
||||
completedSections: number;
|
||||
totalSections: number;
|
||||
sections: SectionProgressDto[];
|
||||
}
|
||||
|
||||
export interface CourseProgressResponse {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
summary: ProgressSummaryDto;
|
||||
chapters: ChapterProgressDto[];
|
||||
refreshedAt: string;
|
||||
mock: boolean;
|
||||
source: string;
|
||||
message: string | null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (!currentPassword.value || !newPassword.value) { message.error('请填写当前密码和新密码。'); return; }
|
||||
if (newPassword.value !== confirmPassword.value) { message.error('两次输入的新密码不一致。'); return; }
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.updatePassword(currentPassword.value, newPassword.value);
|
||||
currentPassword.value = ''; newPassword.value = ''; confirmPassword.value = '';
|
||||
message.success('密码已更新。');
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '密码更新失败。');
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="修改密码">
|
||||
<a-form :label-col="{ xs: { span: 24 }, sm: { span: 4 } }" :wrapper-col="{ xs: { span: 24 }, sm: { span: 12 } }" style="max-width: 600px;">
|
||||
<a-form-item label="当前密码" required>
|
||||
<a-input-password v-model:value="currentPassword" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" required>
|
||||
<a-input-password v-model:value="newPassword" placeholder="至少 6 位" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" required>
|
||||
<a-input-password v-model:value="confirmPassword" placeholder="再次输入新密码" />
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ offset: 4 }">
|
||||
<a-button type="primary" :loading="loading" @click="submit">
|
||||
{{ loading ? '提交中...' : '更新密码' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import UoocLoginModal from '../components/UoocLoginModal.vue';
|
||||
import ZhihuishuLoginModal from '../components/ZhihuishuLoginModal.vue';
|
||||
import { getPlatformCatalog, startBrush } from '../lib/api';
|
||||
import { useCoursesStore } from '../stores/courses';
|
||||
import { usePlatformStore } from '../stores/platform';
|
||||
import type { CatalogResponse, PlatformConnectionDto, PlatformFieldDefinitionDto } from '../types/api';
|
||||
|
||||
const platformStore = usePlatformStore();
|
||||
const coursesStore = useCoursesStore();
|
||||
|
||||
const connectionName = ref('');
|
||||
const loginFormValues = ref<Record<string, string>>({});
|
||||
const courseQueryValues = ref<Record<string, string>>({});
|
||||
const reloginConnectionId = ref<number | null>(null);
|
||||
const showUoocModal = ref(false);
|
||||
const showZhihuishuModal = ref(false);
|
||||
const activeTab = ref('login');
|
||||
|
||||
const catalog = ref<CatalogResponse | null>(null);
|
||||
const loadingCatalog = ref(false);
|
||||
const catalogError = ref('');
|
||||
const submittingBrush = ref(false);
|
||||
|
||||
// Selected chapter IDs
|
||||
const selectedChapterIds = reactive(new Set<string>());
|
||||
|
||||
const selectedPlatformId = computed({
|
||||
get: () => platformStore.selectedPlatformId ?? 0,
|
||||
set: (v: number) => { if (v) { platformStore.selectPlatform(v); reloginConnectionId.value = null; showUoocModal.value = false; showZhihuishuModal.value = false; /* clear previous catalog */ catalog.value = null; catalogError.value = ''; selectedChapterIds.clear(); /* switch to login tab if no active connection for this platform */ const hasActive = platformStore.activeConnection?.platformId === v && platformStore.activeConnection?.status === 'connected'; if (!hasActive) activeTab.value = 'login'; } },
|
||||
});
|
||||
const loginSchema = computed(() => selectedPlatformId.value ? platformStore.loginSchemas[String(selectedPlatformId.value)] ?? null : null);
|
||||
const isUoocPlatform = computed(() => platformStore.selectedPlatform?.slug === 'uooc');
|
||||
const isZhihuishuPlatform = computed(() => platformStore.selectedPlatform?.slug === 'zhihuishu');
|
||||
const activeConnection = computed(() => platformStore.activeConnection);
|
||||
const activeCourseSchema = computed(() => activeConnection.value ? platformStore.courseQuerySchemas[String(activeConnection.value.platformId)] ?? null : null);
|
||||
const activeCourseOptions = computed(() => activeConnection.value ? coursesStore.getCourseOptions(activeConnection.value.id) : []);
|
||||
|
||||
const selectedCourseId = computed({
|
||||
get: () => coursesStore.getSelectedCourseId(activeConnection.value?.id),
|
||||
set: (v: string) => { if (activeConnection.value) coursesStore.setSelectedCourse(activeConnection.value.id, v); },
|
||||
});
|
||||
|
||||
const selectedSectionCount = computed(() => {
|
||||
if (!catalog.value) return 0;
|
||||
let n = 0;
|
||||
for (const ch of catalog.value.chapters) {
|
||||
if (selectedChapterIds.has(ch.id)) n += ch.sections.length;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
watch(() => loginSchema.value, (s) => { loginFormValues.value = s ? applyDefaults(s.fields, loginFormValues.value) : {}; }, { immediate: true });
|
||||
watch(() => activeCourseSchema.value, (s) => { courseQueryValues.value = s ? applyDefaults(s.fields, courseQueryValues.value) : {}; }, { immediate: true });
|
||||
watch(selectedCourseId, async (cid) => { if (cid && activeConnection.value) await loadCatalog(); });
|
||||
onMounted(async () => { if (platformStore.userId && platformStore.platforms.length === 0) await platformStore.bootstrapPlatformContext(); });
|
||||
watch(activeConnection, async (conn) => { if (conn?.status === 'connected') { activeTab.value = 'course'; await loadCourses(); } });
|
||||
|
||||
// Auto-load catalog when entering course tab if a course is already selected
|
||||
watch(activeTab, async (tab) => {
|
||||
if (tab === 'course' && selectedCourseId.value && activeConnection.value && !catalog.value) {
|
||||
await loadCatalog();
|
||||
}
|
||||
});
|
||||
|
||||
function applyDefaults(fields: PlatformFieldDefinitionDto[], cur: Record<string, string>) {
|
||||
return Object.fromEntries(fields.map(f => [f.key, cur[f.key] ?? f.defaultValue ?? ''])) as Record<string, string>;
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (!selectedPlatformId.value) { message.warning('请选择平台。'); return; }
|
||||
try {
|
||||
if (reloginConnectionId.value) await platformStore.reloginConnection(reloginConnectionId.value, loginFormValues.value);
|
||||
else await platformStore.createConnection(selectedPlatformId.value, connectionName.value, loginFormValues.value);
|
||||
await platformStore.loadConnections();
|
||||
if (platformStore.activeConnection?.status === 'connected') activeTab.value = 'course';
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '连接失败。'); }
|
||||
}
|
||||
|
||||
function onUoocLoginSuccess() { showUoocModal.value = false; activeTab.value = 'course'; }
|
||||
function onZhihuishuLoginSuccess() { showZhihuishuModal.value = false; activeTab.value = 'course'; }
|
||||
|
||||
async function loadCourses() {
|
||||
if (!activeConnection.value) return;
|
||||
await platformStore.ensureCourseQuerySchema(activeConnection.value.platformId);
|
||||
try { await coursesStore.queryCourses(activeConnection.value.id, courseQueryValues.value); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '课程加载失败。'); }
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
if (!activeConnection.value || !selectedCourseId.value) return;
|
||||
loadingCatalog.value = true; catalogError.value = '';
|
||||
selectedChapterIds.clear();
|
||||
try { catalog.value = await getPlatformCatalog(activeConnection.value.id, selectedCourseId.value); }
|
||||
catch (err) { catalogError.value = err instanceof Error ? err.message : '加载失败。'; catalog.value = null; }
|
||||
finally { loadingCatalog.value = false; }
|
||||
}
|
||||
|
||||
function toggleChapter(chId: string, checked: boolean) {
|
||||
if (checked) selectedChapterIds.add(chId);
|
||||
else selectedChapterIds.delete(chId);
|
||||
}
|
||||
|
||||
function clearSelection() { selectedChapterIds.clear(); }
|
||||
|
||||
async function startBrushing() {
|
||||
if (!catalog.value || !selectedCourseId.value) return;
|
||||
const chapters = catalog.value.chapters
|
||||
.filter(ch => selectedChapterIds.has(ch.id))
|
||||
.map(ch => ({
|
||||
chapterId: ch.id,
|
||||
chapterName: ch.name,
|
||||
sections: ch.sections.map(sec => ({
|
||||
sectionId: sec.id,
|
||||
sectionName: sec.name,
|
||||
videos: [] as any[],
|
||||
})),
|
||||
}));
|
||||
|
||||
if (chapters.length === 0) { message.warning('请至少选择一个章节。'); return; }
|
||||
|
||||
submittingBrush.value = true;
|
||||
try {
|
||||
await startBrush({ courseId: selectedCourseId.value, chapters });
|
||||
clearSelection();
|
||||
message.success('刷课任务已提交,可前往"查询进度"页面查看。');
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '提交失败。'); }
|
||||
finally { submittingBrush.value = false; }
|
||||
}
|
||||
|
||||
async function activateConnection(c: PlatformConnectionDto) { await platformStore.activateConnection(c.id); }
|
||||
function startRelogin(c: PlatformConnectionDto) { selectedPlatformId.value = c.platformId; reloginConnectionId.value = c.id; connectionName.value = c.connectionName; }
|
||||
function resetToCreate() { reloginConnectionId.value = null; connectionName.value = ''; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="学习平台">
|
||||
<!-- Platform selector always visible above tabs -->
|
||||
<a-row :gutter="[16, 12]" style="margin-bottom: 16px;">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-select v-model:value="selectedPlatformId" placeholder="请选择平台" style="width: 100%;">
|
||||
<a-select-option :value="0">请选择平台</a-select-option>
|
||||
<a-select-option v-for="p in platformStore.platforms" :key="p.id" :value="p.id">{{ p.displayName }}</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-tag v-for="c in platformStore.connections.filter(x => x.platformId === selectedPlatformId)" :key="c.id"
|
||||
:color="c.status === 'connected' ? 'success' : 'default'">
|
||||
{{ c.connectionName }}
|
||||
</a-tag>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="login" tab="连接平台">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="连接名称">
|
||||
<a-input v-model:value="connectionName" placeholder="例如:我的 UOOC" :disabled="Boolean(reloginConnectionId)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-space v-if="platformStore.connections.filter(c => c.platformId === selectedPlatformId).length > 0" style="margin-bottom: 16px;" wrap>
|
||||
<a-tag v-for="c in platformStore.connections.filter(x => x.platformId === selectedPlatformId)" :key="c.id" closable @close="startRelogin(c)">{{ c.connectionName }}</a-tag>
|
||||
<a-button size="small" type="text" @click="resetToCreate">+ 新建</a-button>
|
||||
</a-space>
|
||||
|
||||
<template v-if="isUoocPlatform && selectedPlatformId">
|
||||
<a-button type="primary" size="large" @click="showUoocModal = true">登录 UOOC 平台</a-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isZhihuishuPlatform && selectedPlatformId">
|
||||
<a-button type="primary" size="large" @click="showZhihuishuModal = true">登录智慧树平台</a-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="loginSchema && selectedPlatformId">
|
||||
<a-form-item v-for="field in loginSchema.fields.filter(f => f.type !== 'hidden')" :key="field.key" :label="field.label" :required="field.isRequired" :help="field.helpText ?? undefined">
|
||||
<a-textarea v-if="field.type === 'textarea'" v-model:value="loginFormValues[field.key]" :rows="3" />
|
||||
<a-select v-else-if="field.type === 'select'" v-model:value="loginFormValues[field.key]">
|
||||
<a-select-option v-for="o in field.options" :key="o.value" :value="o.value">{{ o.label }}</a-select-option>
|
||||
</a-select>
|
||||
<a-input-password v-else-if="field.type === 'password'" v-model:value="loginFormValues[field.key]" />
|
||||
<a-input-number v-else-if="field.type === 'number'" v-model:value="loginFormValues[field.key]" style="width: 100%;" />
|
||||
<a-input v-else v-model:value="loginFormValues[field.key]" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" @click="submitLogin">{{ reloginConnectionId ? '重新登录' : '登录并保存' }}</a-button>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="platformStore.currentChallenge" type="info" style="margin-top: 16px;" :message="platformStore.currentChallenge.message" />
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="course" tab="课程学习">
|
||||
<a-empty v-if="!activeConnection" description="请先在连接平台标签页登录。" />
|
||||
|
||||
<template v-else>
|
||||
<a-card size="small" title="步骤 1:选择课程" style="margin-bottom: 16px;">
|
||||
<a-flex :gap="8" wrap="wrap" align="start">
|
||||
<a-select v-model:value="selectedCourseId" placeholder="请先加载课程下拉" style="flex: 1; min-width: 200px; max-width: 400px;" :loading="coursesStore.isLoadingCourses(activeConnection?.id)">
|
||||
<a-select-option v-for="c in activeCourseOptions" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" :loading="coursesStore.isLoadingCourses(activeConnection?.id)" @click="loadCourses">
|
||||
{{ coursesStore.isLoadingCourses(activeConnection?.id) ? '加载中...' : activeCourseOptions.length === 0 ? '加载课程列表' : '刷新' }}
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<div v-if="activeCourseSchema" style="margin-top: 8px;">
|
||||
<a-flex :gap="8" wrap="wrap">
|
||||
<a-input v-for="field in activeCourseSchema.fields.filter(f => f.type !== 'hidden')" :key="field.key" v-model:value="courseQueryValues[field.key]" :placeholder="field.label" :addon-before="field.label" style="min-width: 160px; max-width: 240px;" />
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-alert v-if="coursesStore.messageByConnection[String(activeConnection.id)]" type="info" :message="coursesStore.messageByConnection[String(activeConnection.id)]" style="margin-top: 8px;" />
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="selectedCourseId" size="small" title="步骤 2:章节列表">
|
||||
<a-alert v-if="catalogError" type="error" :message="catalogError" style="margin-bottom: 12px;" />
|
||||
<a-skeleton v-if="loadingCatalog" active :paragraph="{ rows: 6 }" />
|
||||
<template v-else>
|
||||
<a-empty v-if="catalog && catalog.chapters.length === 0" description="暂无章节数据" />
|
||||
|
||||
<a-collapse v-if="catalog">
|
||||
<a-collapse-panel v-for="ch in catalog.chapters" :key="ch.id">
|
||||
<template #header>
|
||||
<a-flex align="center" :gap="8" wrap="wrap">
|
||||
<a-checkbox
|
||||
:checked="selectedChapterIds.has(ch.id)"
|
||||
@change="(e: any) => toggleChapter(ch.id, e.target.checked)"
|
||||
@click.stop
|
||||
/>
|
||||
<strong>{{ ch.name }}</strong>
|
||||
<a-tag :color="ch.finished ? 'green' : ch.learning ? 'blue' : 'default'">
|
||||
{{ ch.finished ? '已完成' : ch.learning ? '学习中' : '未开始' }}
|
||||
</a-tag>
|
||||
<a-typography-text type="secondary">
|
||||
{{ ch.sections.length }} 节
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-list :data-source="ch.sections" size="small">
|
||||
<template #renderItem="{ item: sec }">
|
||||
<a-list-item>
|
||||
{{ sec.name }}
|
||||
<template #actions>
|
||||
<a-tag :color="sec.finished ? 'green' : sec.learning ? 'blue' : 'default'">
|
||||
{{ sec.finished ? '已完成' : sec.learning ? '学习中' : '-' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- Selection bar -->
|
||||
<div v-if="selectedChapterIds.size > 0" class="selection-bar">
|
||||
<strong>已选 {{ selectedChapterIds.size }} 章({{ selectedSectionCount }} 节)</strong>
|
||||
<a-space>
|
||||
<a-button @click="clearSelection">清空</a-button>
|
||||
<a-button type="primary" :loading="submittingBrush" @click="startBrushing">开始刷课</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<UoocLoginModal
|
||||
v-if="showUoocModal && isUoocPlatform && selectedPlatformId"
|
||||
:platform-id="selectedPlatformId"
|
||||
:connection-name="connectionName"
|
||||
@success="onUoocLoginSuccess"
|
||||
@close="showUoocModal = false"
|
||||
/>
|
||||
|
||||
<ZhihuishuLoginModal
|
||||
v-if="showZhihuishuModal && isZhihuishuPlatform && selectedPlatformId"
|
||||
:platform-id="selectedPlatformId"
|
||||
:connection-name="connectionName"
|
||||
@success="onZhihuishuLoginSuccess"
|
||||
@close="showZhihuishuModal = false"
|
||||
/>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-top: 2px solid #c35a2d;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 50;
|
||||
box-shadow: 0 -4px 12px rgba(0,0,0,0.1);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.selection-bar {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<a-result
|
||||
status="403"
|
||||
title="403"
|
||||
sub-title="当前账号没有访问这个页面的权限。"
|
||||
>
|
||||
<template #extra>
|
||||
<RouterLink to="/courses">
|
||||
<a-button type="primary">返回刷课页</a-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { usePlatformStore } from '../stores/platform';
|
||||
|
||||
const platformStore = usePlatformStore();
|
||||
const activeId = computed(() => platformStore.activeConnection?.id ?? null);
|
||||
|
||||
onMounted(async () => {
|
||||
if (platformStore.userId && platformStore.platforms.length === 0) {
|
||||
await platformStore.bootstrapPlatformContext();
|
||||
}
|
||||
});
|
||||
|
||||
async function activate(id: number) { await platformStore.activateConnection(id); }
|
||||
async function remove(id: number) { await platformStore.deleteConnection(id); message.success('已删除。'); }
|
||||
|
||||
function tagColor(status: string) {
|
||||
switch (status) { case 'connected': return 'green'; case 'challenge_pending': return 'blue'; case 'failed': return 'red'; default: return 'orange'; }
|
||||
}
|
||||
function tagLabel(status: string) {
|
||||
switch (status) { case 'connected': return '已连接'; case 'challenge_pending': return '等待验证'; case 'failed': return '失败'; default: return '处理中'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="平台连接中心">
|
||||
<template #extra>
|
||||
<RouterLink to="/courses"><a-button type="primary">去刷课页</a-button></RouterLink>
|
||||
</template>
|
||||
|
||||
<a-empty v-if="platformStore.connections.length === 0" description="暂无连接" />
|
||||
|
||||
<a-list v-else :data-source="platformStore.connections">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-space>
|
||||
{{ item.connectionName }}
|
||||
<a-tag :color="tagColor(item.status)">{{ tagLabel(item.status) }}</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.platformName }}
|
||||
<span v-if="item.platformUserLabel"> · {{ item.platformUserLabel }}</span>
|
||||
<br />
|
||||
创建 {{ new Date(item.createdAt).toLocaleString() }}
|
||||
· 最后登录 {{ item.lastSuccessfulLoginAt ? new Date(item.lastSuccessfulLoginAt).toLocaleString() : '-' }}
|
||||
· 校验 {{ item.lastValidatedAt ? new Date(item.lastValidatedAt).toLocaleString() : '-' }}
|
||||
<a-alert v-if="item.lastError" type="error" :message="item.lastError" style="margin-top: 8px;" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-tag :color="item.id === activeId ? 'green' : 'default'">
|
||||
{{ item.id === activeId ? '当前' : '可切换' }}
|
||||
</a-tag>
|
||||
<a-button size="small" :disabled="item.id === activeId" @click="activate(item.id)">设为当前</a-button>
|
||||
<a-button size="small" danger @click="remove(item.id)">删除</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="个人信息">
|
||||
<template #extra>
|
||||
<a-tag :color="auth.isAdmin ? 'blue' : 'default'">{{ auth.roleLabel }}</a-tag>
|
||||
</template>
|
||||
<a-descriptions bordered :column="1" size="small">
|
||||
<a-descriptions-item label="用户名">{{ auth.currentUser?.username }}</a-descriptions-item>
|
||||
<a-descriptions-item label="显示名">{{ auth.currentUser?.displayName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="auth.currentUser?.status === 'active' ? 'green' : 'red'">
|
||||
{{ auth.currentUser?.status === 'active' ? '正常' : '停用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最近登录">
|
||||
{{ auth.currentUser?.lastLoginAt ? new Date(auth.currentUser.lastLoginAt).toLocaleString() : '暂无' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ auth.currentUser?.createdAt ? new Date(auth.currentUser.createdAt).toLocaleString() : '暂无' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { getBrushStatus, retryBrush, stopBrushTask, retryBrushTask, deleteBrushTask } from '../lib/api';
|
||||
import type { BrushStatusDto } from '../types/api';
|
||||
|
||||
const tasks = ref<BrushStatusDto[]>([]);
|
||||
const actionLoading = ref<Record<string, boolean>>({});
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(startBrushPolling);
|
||||
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); });
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
tasks.value = await getBrushStatus();
|
||||
} catch { tasks.value = []; }
|
||||
}
|
||||
|
||||
function startBrushPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
fetchStatus();
|
||||
pollTimer = setInterval(fetchStatus, 2000);
|
||||
}
|
||||
|
||||
function setLoading(taskId: string, v: boolean) {
|
||||
actionLoading.value = { ...actionLoading.value, [taskId]: v };
|
||||
}
|
||||
|
||||
async function handleStop(taskId: string) {
|
||||
setLoading(taskId, true);
|
||||
try {
|
||||
await stopBrushTask(taskId);
|
||||
message.success('已停止。');
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '操作失败。');
|
||||
} finally { setLoading(taskId, false); }
|
||||
}
|
||||
|
||||
async function handleRetry(taskId: string) {
|
||||
setLoading(taskId, true);
|
||||
try {
|
||||
await retryBrushTask(taskId);
|
||||
message.success('已重新开始。');
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '重试失败。');
|
||||
} finally { setLoading(taskId, false); }
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: string) {
|
||||
setLoading(taskId, true);
|
||||
try {
|
||||
await deleteBrushTask(taskId);
|
||||
message.success('已删除。');
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '删除失败。');
|
||||
} finally { setLoading(taskId, false); }
|
||||
}
|
||||
|
||||
const platformLabel = (slug: string) => slug === 'zhihuishu' ? '智慧树' : 'UOOC';
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
if (status === 'Running') return 'processing';
|
||||
if (status === 'Queued') return 'warning';
|
||||
if (status === 'Completed') return 'success';
|
||||
if (status === 'Failed') return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === 'Running') return '进行中';
|
||||
if (status === 'Queued') return '排队中';
|
||||
if (status === 'Completed') return '已完成';
|
||||
if (status === 'Failed') return '已失败';
|
||||
if (status === 'Stopped') return '已停止';
|
||||
return status;
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
|
||||
return `${Math.floor(diff / 86400)} 天前`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${Math.round(seconds)} 秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h} 小时 ${m} 分`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="刷课进度">
|
||||
<a-empty v-if="tasks.length === 0" description="当前没有刷课任务。" />
|
||||
|
||||
<template v-else>
|
||||
<a-alert
|
||||
v-if="tasks[0].queuedCount > 0"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #message>
|
||||
还有 {{ tasks[0].queuedCount }} 个任务在排队等待执行
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col v-for="t in tasks" :key="t.taskId" :xs="24" :lg="12">
|
||||
<a-card size="small">
|
||||
<template #title>
|
||||
{{ platformLabel(t.platformSlug) }} · {{ t.courseId }}
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-tag :color="statusColor(t.status)">{{ statusLabel(t.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<a-typography-text type="secondary" style="font-size: 12px;">
|
||||
{{ t.createdAt ? timeAgo(t.createdAt) + '提交' : '' }}
|
||||
<template v-if="t.status === 'Running' || t.status === 'Queued'">
|
||||
· 已运行 {{ formatDuration(t.durationSeconds) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
· 耗时 {{ formatDuration(t.durationSeconds) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
|
||||
<div style="font-weight: 500; margin-top: 4px; font-size: 13px; color: #666;">
|
||||
{{ t.chaptersSummary }}
|
||||
</div>
|
||||
|
||||
<a-descriptions size="small" :column="1" style="margin: 8px 0;">
|
||||
<a-descriptions-item label="进度">
|
||||
{{ t.completedVideos }} / {{ t.totalVideos }}
|
||||
<a-tag
|
||||
v-if="t.retryCount > 0"
|
||||
:color="t.retryCount >= 3 ? 'error' : 'warning'"
|
||||
style="margin-left: 8px;"
|
||||
>
|
||||
重试 {{ t.retryCount }}/3
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="t.status === 'Running'" label="章节">{{ t.currentChapterName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="t.status === 'Running'" label="小节">{{ t.currentSectionName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="t.status === 'Running'" label="资源">{{ t.currentVideoTitle || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-alert
|
||||
v-if="t.lastError"
|
||||
type="error"
|
||||
:message="t.lastError"
|
||||
style="margin-bottom: 8px;"
|
||||
/>
|
||||
|
||||
<a-row v-if="t.status === 'Running'" align="middle" style="margin-bottom: 8px;">
|
||||
<a-progress
|
||||
:percent="t.currentVideoLength > 0 ? Math.min(100, Math.round(t.currentVideoPos / t.currentVideoLength * 100)) : 0"
|
||||
:format="() => `${Math.round(t.currentVideoPos)}s / ${Math.round(t.currentVideoLength || 0)}s`"
|
||||
style="flex: 1;"
|
||||
/>
|
||||
</a-row>
|
||||
|
||||
<a-space style="margin-top: 4px;">
|
||||
<a-popconfirm
|
||||
v-if="t.status === 'Running' || t.status === 'Queued'"
|
||||
title="确定停止该任务?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleStop(t.taskId)"
|
||||
>
|
||||
<a-button size="small" danger :loading="actionLoading[t.taskId]">
|
||||
停止
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button
|
||||
v-if="t.status === 'Failed'"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="actionLoading[t.taskId]"
|
||||
@click="handleRetry(t.taskId)"
|
||||
>
|
||||
重试
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="t.status === 'Completed' || t.status === 'Failed' || t.status === 'Stopped'"
|
||||
title="确定删除该任务记录?"
|
||||
ok-text="确定删除"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(t.taskId)"
|
||||
>
|
||||
<a-button size="small" :loading="actionLoading[t.taskId]">
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { createInviteCode, getInviteCodes, updateInviteCode } from '../../lib/api';
|
||||
import type { InviteCodeDto } from '../../types/api';
|
||||
|
||||
const invites = ref<InviteCodeDto[]>([]);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const savingId = ref<number | null>(null);
|
||||
const form = reactive({ code: '', maxUses: 1, expiresAt: '' });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try { invites.value = await getInviteCodes(); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
creating.value = true;
|
||||
try {
|
||||
const inv = await createInviteCode({
|
||||
code: form.code.trim() || undefined,
|
||||
maxUses: Number(form.maxUses) || 1,
|
||||
expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined,
|
||||
});
|
||||
invites.value = [inv, ...invites.value];
|
||||
message.success(`已创建 ${inv.code}。`);
|
||||
form.code = ''; form.maxUses = 1; form.expiresAt = '';
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '创建失败。'); }
|
||||
finally { creating.value = false; }
|
||||
}
|
||||
|
||||
async function toggle(inv: InviteCodeDto) {
|
||||
savingId.value = inv.id;
|
||||
try {
|
||||
const u = await updateInviteCode(inv.id, { status: inv.status === 'active' ? 'disabled' : 'active' });
|
||||
invites.value = invites.value.map(x => x.id === u.id ? u : x);
|
||||
message.success(`邀请码 ${u.code} 已${u.status === 'active' ? '启用' : '停用'}。`);
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '更新失败。'); }
|
||||
finally { savingId.value = null; }
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const columns = [
|
||||
{ title: '邀请码', dataIndex: 'code' },
|
||||
{ title: '创建人', dataIndex: 'createdByDisplayName' },
|
||||
{ title: '使用', key: 'usage' },
|
||||
{ title: '过期时间', key: 'expires' },
|
||||
{ title: '状态', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="邀请码管理">
|
||||
<template #extra>
|
||||
<a-button @click="load" :loading="loading">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<a-space style="margin-bottom: 16px;">
|
||||
<a-input v-model:value="form.code" placeholder="邀请码(留空自动生成)" style="width: 200px;" />
|
||||
<a-input-number v-model:value="form.maxUses" :min="1" style="width: 120px;" placeholder="可用次数" />
|
||||
<a-date-picker v-model:value="form.expiresAt" placeholder="过期时间(可选)" show-time />
|
||||
<a-button type="primary" :loading="creating" @click="create">{{ creating ? '...' : '创建' }}</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-table :data-source="invites" :columns="columns" :loading="loading" row-key="id" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'usage'">{{ record.usedCount }} / {{ record.maxUses }}</template>
|
||||
<template v-else-if="column.key === 'expires'">
|
||||
{{ record.expiresAt ? new Date(record.expiresAt).toLocaleString('zh-CN') : '不过期' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status === 'active' ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" :loading="savingId === record.id" @click="toggle(record)">
|
||||
{{ record.status === 'active' ? '停用' : '启用' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { generateNodeToken, getNodes, getNodeTasks, deleteNode, cancelNodeTask } from '../../lib/api';
|
||||
|
||||
interface NodeInfo { id: number; name: string; token: string; lastIp: string | null; lastHeartbeat: string; isOnline: boolean; createdAt: string; }
|
||||
interface TaskInfo { id: number; nodeId: number | null; node: NodeInfo | null; userId: number; courseName: string; platformUrl: string; status: string; totalSteps: number; completedSteps: number; currentStep: string | null; lastError: string | null; updatedAt: string; }
|
||||
|
||||
const nodes = ref<NodeInfo[]>([]);
|
||||
const tasks = ref<TaskInfo[]>([]);
|
||||
const newToken = ref('');
|
||||
let timer: any = null;
|
||||
|
||||
onMounted(async () => { await refresh(); timer = setInterval(refresh, 5000); });
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
async function refresh() {
|
||||
try { nodes.value = await getNodes(); } catch { /* */ }
|
||||
try { tasks.value = await getNodeTasks(); } catch { /* */ }
|
||||
}
|
||||
|
||||
async function genToken() {
|
||||
try { const r = await generateNodeToken(); newToken.value = r.token; }
|
||||
catch { message.error('生成失败'); }
|
||||
}
|
||||
|
||||
async function delNode(id: number) {
|
||||
try { await deleteNode(id); message.success('已删除'); await refresh(); }
|
||||
catch { message.error('删除失败'); }
|
||||
}
|
||||
|
||||
async function cancelTask(id: number) {
|
||||
try { await cancelNodeTask(id); message.success('已取消'); await refresh(); }
|
||||
catch { message.error('取消失败'); }
|
||||
}
|
||||
|
||||
const nodeCols = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 50 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '在线', key: 'online', width: 60 },
|
||||
{ title: 'IP', dataIndex: 'lastIp', width: 130 },
|
||||
{ title: '最后心跳', key: 'hb', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 60 },
|
||||
];
|
||||
|
||||
const taskCols = [
|
||||
{ title: '课程', dataIndex: 'courseName', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 70 },
|
||||
{ title: '进度', key: 'progress', width: 90 },
|
||||
{ title: '节点', key: 'node', width: 100 },
|
||||
{ title: '当前步骤', dataIndex: 'currentStep', ellipsis: true, width: 180 },
|
||||
{ title: '错误', dataIndex: 'lastError', ellipsis: true, width: 150 },
|
||||
{ title: '操作', key: 'action', width: 60 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="节点管理">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="genToken">生成 Token</a-button>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="newToken" type="success" closable @close="newToken = ''" style="margin-bottom: 12px;">
|
||||
新 Token:<a-typography-text copyable code>{{ newToken }}</a-typography-text>(仅显示一次,请复制保存)
|
||||
</a-alert>
|
||||
|
||||
<a-empty v-if="nodes.length === 0" description="暂无节点,先生成 Token,然后在 Windows 上运行 agent。" />
|
||||
|
||||
<a-table v-else :data-source="nodes" :columns="nodeCols" row-key="id" size="small" :pagination="false" style="margin-bottom: 16px;">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'online'">
|
||||
<a-tag :color="record.isOnline ? 'green' : 'red'">{{ record.isOnline ? '在线' : '离线' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'hb'">{{ new Date(record.lastHeartbeat).toLocaleString() }}</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" danger @click="delNode(record.id)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card title="任务列表" style="margin-top: 16px;">
|
||||
<a-empty v-if="tasks.length === 0" description="暂无任务" />
|
||||
<a-table v-else :data-source="tasks" :columns="taskCols" row-key="id" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'Running' ? 'processing' : record.status === 'Completed' ? 'success' : record.status === 'Failed' ? 'error' : 'default'">
|
||||
{{ record.status === 'Running' ? '执行中' : record.status === 'Completed' ? '完成' : record.status === 'Failed' ? '失败' : '等待' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'progress'">{{ record.completedSteps }}/{{ record.totalSteps }}</template>
|
||||
<template v-else-if="column.key === 'node'">{{ record.node?.name || '-' }}</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="record.status === 'Pending' || record.status === 'Running'" size="small" danger @click="cancelTask(record.id)">取消</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
cloneAdminPlatform, createAdminPlatform, getAdminPlatform, getAdminPlatforms,
|
||||
updateAdminPlatform, updateAdminPlatformStatus,
|
||||
} from '../../lib/api';
|
||||
import type {
|
||||
CatalogMappingDto, CourseOptionMappingDto, PlatformCookieMappingDto, PlatformDefinitionDto,
|
||||
PlatformFieldDefinitionDto, PlatformOutputVariableDto, PlatformSummaryDto,
|
||||
PlatformWorkflowStepDto, SavePlatformDefinitionRequest, SelectOptionDto, UnitMappingDto,
|
||||
} from '../../types/api';
|
||||
|
||||
interface FieldEditor {
|
||||
scope: 'login' | 'course_query'; key: string; label: string; type: PlatformFieldDefinitionDto['type'];
|
||||
isRequired: boolean; displayOrder: number; placeholder: string; helpText: string;
|
||||
defaultValue: string; isSensitive: boolean; optionsText: string;
|
||||
}
|
||||
interface StepEditor {
|
||||
scope: PlatformWorkflowStepDto['scope']; stepKey: string; displayName: string; displayOrder: number;
|
||||
stepType: PlatformWorkflowStepDto['stepType']; httpMethod: string; urlTemplate: string;
|
||||
queryTemplateJson: string; headersTemplateJson: string; bodyTemplateJson: string; contentType: string;
|
||||
successPath: string; successExpectedValue: string; platformUserLabelExpression: string;
|
||||
outputCookiesText: string; outputVariablesText: string; courseOptionMappingText: string;
|
||||
catalogMappingText: string; unitMappingText: string; browserSuccessUrlContains: string;
|
||||
browserSuccessCookieName: string; browserWaitForSelector: string; browserTimeoutSeconds: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const loading = ref(false); const saving = ref(false);
|
||||
const platforms = ref<PlatformSummaryDto[]>([]);
|
||||
const editingPlatformId = ref<number | null>(null);
|
||||
const activeTab = ref('list');
|
||||
|
||||
const editor = reactive({
|
||||
slug: '', displayName: '', description: '', status: 'draft' as SavePlatformDefinitionRequest['status'],
|
||||
enableBrowserChallenge: false, courseQueryStepKey: '',
|
||||
supportsCatalog: true, supportsUnits: true, supportsProgress: true, challengeTimeoutSeconds: 600,
|
||||
fields: [] as FieldEditor[], steps: [] as StepEditor[],
|
||||
});
|
||||
|
||||
const courseQueryStepOptions = computed(() =>
|
||||
editor.steps.filter(s => s.scope === 'course_query').map(s => s.stepKey).filter(Boolean));
|
||||
|
||||
onMounted(async () => { await loadPlatforms(); resetEditor(); });
|
||||
|
||||
async function loadPlatforms() {
|
||||
loading.value = true;
|
||||
try { platforms.value = await getAdminPlatforms(); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
editingPlatformId.value = null;
|
||||
Object.assign(editor, {
|
||||
slug: '', displayName: '', description: '', status: 'draft' as const,
|
||||
enableBrowserChallenge: false, courseQueryStepKey: '',
|
||||
supportsCatalog: true, supportsUnits: true, supportsProgress: true, challengeTimeoutSeconds: 600,
|
||||
fields: [createField('login'), createField('course_query')], steps: [createStep('login')],
|
||||
});
|
||||
}
|
||||
|
||||
function createField(s: 'login' | 'course_query'): FieldEditor {
|
||||
return { scope: s, key: '', label: '', type: 'text', isRequired: true,
|
||||
displayOrder: editor.fields.filter(f => f.scope === s).length + 1,
|
||||
placeholder: '', helpText: '', defaultValue: '', isSensitive: false, optionsText: '[]' };
|
||||
}
|
||||
function createStep(s: PlatformWorkflowStepDto['scope']): StepEditor {
|
||||
return { scope: s, stepKey: '', displayName: '',
|
||||
displayOrder: editor.steps.filter(st => st.scope === s).length + 1,
|
||||
stepType: 'http_request', httpMethod: 'GET', urlTemplate: '', queryTemplateJson: '',
|
||||
headersTemplateJson: '', bodyTemplateJson: '', contentType: '', successPath: '',
|
||||
successExpectedValue: '', platformUserLabelExpression: '', outputCookiesText: '[]',
|
||||
outputVariablesText: '[]', courseOptionMappingText: '', catalogMappingText: '',
|
||||
unitMappingText: '', browserSuccessUrlContains: '', browserSuccessCookieName: '',
|
||||
browserWaitForSelector: '', browserTimeoutSeconds: '', isEnabled: true };
|
||||
}
|
||||
|
||||
async function editPlatform(id: number) {
|
||||
loading.value = true;
|
||||
try { fillEditor(await getAdminPlatform(id)); activeTab.value = 'editor'; }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function duplicatePlatform(id: number) {
|
||||
await cloneAdminPlatform(id); message.success('已复制。'); await loadPlatforms();
|
||||
}
|
||||
|
||||
async function toggleStatus(p: PlatformSummaryDto) {
|
||||
await updateAdminPlatformStatus(p.id, { status: p.status === 'active' ? 'disabled' : 'active' });
|
||||
await loadPlatforms();
|
||||
}
|
||||
|
||||
function startNew() { resetEditor(); activeTab.value = 'editor'; }
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (editingPlatformId.value) {
|
||||
await updateAdminPlatform(editingPlatformId.value, payload); message.success('已更新。');
|
||||
} else {
|
||||
await createAdminPlatform(payload); message.success('已创建。');
|
||||
}
|
||||
await loadPlatforms(); activeTab.value = 'list';
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '保存失败。'); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
|
||||
function buildPayload(): SavePlatformDefinitionRequest {
|
||||
return {
|
||||
slug: editor.slug.trim(), displayName: editor.displayName.trim(), description: editor.description.trim(),
|
||||
status: editor.status, enableBrowserChallenge: editor.enableBrowserChallenge,
|
||||
courseQueryStepKey: editor.courseQueryStepKey.trim() || null,
|
||||
supportsCatalog: editor.supportsCatalog, supportsUnits: editor.supportsUnits, supportsProgress: editor.supportsProgress,
|
||||
challengeTimeoutSeconds: Number(editor.challengeTimeoutSeconds) || 600,
|
||||
fields: editor.fields.map(f => ({
|
||||
scope: f.scope, key: f.key.trim(), label: f.label.trim(), type: f.type,
|
||||
isRequired: f.isRequired, displayOrder: Number(f.displayOrder),
|
||||
placeholder: f.placeholder.trim() || null, helpText: f.helpText.trim() || null,
|
||||
defaultValue: f.defaultValue.trim() || null, isSensitive: f.isSensitive,
|
||||
options: parseJson<SelectOptionDto[]>(f.optionsText, []),
|
||||
})),
|
||||
steps: editor.steps.map(s => ({
|
||||
scope: s.scope, stepKey: s.stepKey.trim(), displayName: s.displayName.trim(),
|
||||
displayOrder: Number(s.displayOrder), stepType: s.stepType,
|
||||
httpMethod: s.httpMethod.trim() || 'GET', urlTemplate: s.urlTemplate.trim() || null,
|
||||
queryTemplateJson: s.queryTemplateJson.trim() || null, headersTemplateJson: s.headersTemplateJson.trim() || null,
|
||||
bodyTemplateJson: s.bodyTemplateJson.trim() || null, contentType: s.contentType.trim() || null,
|
||||
successPath: s.successPath.trim() || null, successExpectedValue: s.successExpectedValue.trim() || null,
|
||||
platformUserLabelExpression: s.platformUserLabelExpression.trim() || null,
|
||||
outputCookies: parseJson<PlatformCookieMappingDto[]>(s.outputCookiesText, []),
|
||||
outputVariables: parseJson<PlatformOutputVariableDto[]>(s.outputVariablesText, []),
|
||||
courseOptionMapping: parseJson<CourseOptionMappingDto | null>(s.courseOptionMappingText, null),
|
||||
catalogMapping: parseJson<CatalogMappingDto | null>(s.catalogMappingText, null),
|
||||
unitMapping: parseJson<UnitMappingDto | null>(s.unitMappingText, null),
|
||||
browserSuccessUrlContains: s.browserSuccessUrlContains.trim() || null,
|
||||
browserSuccessCookieName: s.browserSuccessCookieName.trim() || null,
|
||||
browserWaitForSelector: s.browserWaitForSelector.trim() || null,
|
||||
browserTimeoutSeconds: s.browserTimeoutSeconds.trim() ? Number(s.browserTimeoutSeconds) : null,
|
||||
isEnabled: s.isEnabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function fillEditor(p: PlatformDefinitionDto) {
|
||||
editingPlatformId.value = p.id;
|
||||
editor.slug = p.slug; editor.displayName = p.displayName; editor.description = p.description;
|
||||
editor.status = p.status; editor.enableBrowserChallenge = p.enableBrowserChallenge;
|
||||
editor.courseQueryStepKey = p.courseQueryStepKey ?? '';
|
||||
editor.supportsCatalog = p.supportsCatalog; editor.supportsUnits = p.supportsUnits;
|
||||
editor.supportsProgress = p.supportsProgress; editor.challengeTimeoutSeconds = p.challengeTimeoutSeconds;
|
||||
editor.fields = [...p.loginFields, ...p.courseQueryFields].map(f => ({
|
||||
scope: f.scope, key: f.key, label: f.label, type: f.type, isRequired: f.isRequired,
|
||||
displayOrder: f.displayOrder, placeholder: f.placeholder ?? '', helpText: f.helpText ?? '',
|
||||
defaultValue: f.defaultValue ?? '', isSensitive: f.isSensitive,
|
||||
optionsText: JSON.stringify(f.options, null, 2),
|
||||
}));
|
||||
editor.steps = [...p.loginSteps, ...p.courseQuerySteps, ...p.catalogSteps, ...p.unitSteps, ...p.progressSteps].map(s => ({
|
||||
scope: s.scope, stepKey: s.stepKey, displayName: s.displayName, displayOrder: s.displayOrder,
|
||||
stepType: s.stepType, httpMethod: s.httpMethod,
|
||||
urlTemplate: s.urlTemplate ?? '', queryTemplateJson: s.queryTemplateJson ?? '',
|
||||
headersTemplateJson: s.headersTemplateJson ?? '', bodyTemplateJson: s.bodyTemplateJson ?? '',
|
||||
contentType: s.contentType ?? '', successPath: s.successPath ?? '',
|
||||
successExpectedValue: s.successExpectedValue ?? '', platformUserLabelExpression: s.platformUserLabelExpression ?? '',
|
||||
outputCookiesText: JSON.stringify(s.outputCookies, null, 2),
|
||||
outputVariablesText: JSON.stringify(s.outputVariables, null, 2),
|
||||
courseOptionMappingText: s.courseOptionMapping ? JSON.stringify(s.courseOptionMapping, null, 2) : '',
|
||||
catalogMappingText: s.catalogMapping ? JSON.stringify(s.catalogMapping, null, 2) : '',
|
||||
unitMappingText: s.unitMapping ? JSON.stringify(s.unitMapping, null, 2) : '',
|
||||
browserSuccessUrlContains: s.browserSuccessUrlContains ?? '', browserSuccessCookieName: s.browserSuccessCookieName ?? '',
|
||||
browserWaitForSelector: s.browserWaitForSelector ?? '', browserTimeoutSeconds: s.browserTimeoutSeconds ? String(s.browserTimeoutSeconds) : '',
|
||||
isEnabled: s.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseJson<T>(value: string, fallback: T): T { return value.trim() ? JSON.parse(value) as T : fallback; }
|
||||
|
||||
const platformColumns = [
|
||||
{ title: '标识', dataIndex: 'slug' },
|
||||
{ title: '名称', dataIndex: 'displayName' },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="平台管理">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="startNew"><PlusOutlined /> 新建平台</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :data-source="platforms" :columns="platformColumns" :loading="loading" row-key="id" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'disabled' ? 'red' : 'orange'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="editPlatform(record.id)">编辑</a-button>
|
||||
<a-button size="small" @click="duplicatePlatform(record.id)">复制</a-button>
|
||||
<a-button size="small" type="text" @click="toggleStatus(record)">
|
||||
{{ record.status === 'active' ? '停用' : '启用' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- Editor -->
|
||||
<a-card :title="editingPlatformId ? '编辑平台' : '新建平台'" size="small" type="inner">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6"><a-form-item label="平台标识"><a-input v-model:value="editor.slug" /></a-form-item></a-col>
|
||||
<a-col :span="6"><a-form-item label="显示名称"><a-input v-model:value="editor.displayName" /></a-form-item></a-col>
|
||||
<a-col :span="4"><a-form-item label="状态">
|
||||
<a-select v-model:value="editor.status">
|
||||
<a-select-option value="draft">draft</a-select-option>
|
||||
<a-select-option value="active">active</a-select-option>
|
||||
<a-select-option value="disabled">disabled</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item></a-col>
|
||||
<a-col :span="4"><a-form-item label="课程步骤">
|
||||
<a-select v-model:value="editor.courseQueryStepKey" allow-clear placeholder="不指定">
|
||||
<a-select-option v-for="sk in courseQueryStepOptions" :key="sk" :value="sk">{{ sk }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item></a-col>
|
||||
<a-col :span="4"><a-form-item label="挑战超时"><a-input-number v-model:value="editor.challengeTimeoutSeconds" :min="30" /></a-form-item></a-col>
|
||||
<a-col :span="24"><a-form-item label="描述"><a-textarea v-model:value="editor.description" :rows="2" /></a-form-item></a-col>
|
||||
<a-col :span="24">
|
||||
<a-space>
|
||||
<a-checkbox v-model:checked="editor.enableBrowserChallenge">允许浏览器挑战</a-checkbox>
|
||||
<a-checkbox v-model:checked="editor.supportsCatalog">章节目录</a-checkbox>
|
||||
<a-checkbox v-model:checked="editor.supportsUnits">资源读取</a-checkbox>
|
||||
<a-checkbox v-model:checked="editor.supportsProgress">进度聚合</a-checkbox>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<!-- Fields -->
|
||||
<a-divider orientation="left">动态字段</a-divider>
|
||||
<a-space style="margin-bottom: 12px;">
|
||||
<a-button size="small" @click="editor.fields.push(createField('login'))"><PlusOutlined /> 登录字段</a-button>
|
||||
<a-button size="small" @click="editor.fields.push(createField('course_query'))"><PlusOutlined /> 课程字段</a-button>
|
||||
</a-space>
|
||||
<a-row v-for="(f, i) in editor.fields" :key="`f-${i}`" :gutter="8" style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px dashed #f0f0f0;">
|
||||
<a-col :span="3"><a-select v-model:value="f.scope" size="small"><a-select-option value="login">login</a-select-option><a-select-option value="course_query">course_query</a-select-option></a-select></a-col>
|
||||
<a-col :span="3"><a-input v-model:value="f.key" size="small" placeholder="键名" /></a-col>
|
||||
<a-col :span="3"><a-input v-model:value="f.label" size="small" placeholder="标签" /></a-col>
|
||||
<a-col :span="3"><a-select v-model:value="f.type" size="small"><a-select-option value="text">text</a-select-option><a-select-option value="password">password</a-select-option><a-select-option value="number">number</a-select-option><a-select-option value="select">select</a-select-option><a-select-option value="textarea">textarea</a-select-option><a-select-option value="captcha_text">captcha_text</a-select-option><a-select-option value="hidden">hidden</a-select-option></a-select></a-col>
|
||||
<a-col :span="2"><a-input-number v-model:value="f.displayOrder" :min="1" size="small" /></a-col>
|
||||
<a-col :span="3"><a-input v-model:value="f.defaultValue" size="small" placeholder="默认值" /></a-col>
|
||||
<a-col :span="4"><a-textarea v-model:value="f.optionsText" size="small" :rows="1" placeholder="Options JSON" /></a-col>
|
||||
<a-col :span="3">
|
||||
<a-checkbox v-model:checked="f.isRequired">必填</a-checkbox>
|
||||
<a-checkbox v-model:checked="f.isSensitive">敏感</a-checkbox>
|
||||
<a-button type="text" danger size="small" @click="editor.fields.splice(i, 1)">删</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Steps -->
|
||||
<a-divider orientation="left">工作流步骤</a-divider>
|
||||
<a-space style="margin-bottom: 12px;">
|
||||
<a-button size="small" @click="editor.steps.push(createStep('login'))"><PlusOutlined /> 登录</a-button>
|
||||
<a-button size="small" @click="editor.steps.push(createStep('course_query'))"><PlusOutlined /> 课程</a-button>
|
||||
<a-button size="small" @click="editor.steps.push(createStep('catalog'))"><PlusOutlined /> 目录</a-button>
|
||||
<a-button size="small" @click="editor.steps.push(createStep('units'))"><PlusOutlined /> 资源</a-button>
|
||||
<a-button size="small" @click="editor.steps.push(createStep('progress'))"><PlusOutlined /> 进度</a-button>
|
||||
</a-space>
|
||||
<a-row v-for="(s, i) in editor.steps" :key="`s-${i}`" :gutter="8" style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px dashed #f0f0f0;">
|
||||
<a-col :span="2"><a-select v-model:value="s.scope" size="small"><a-select-option value="login">login</a-select-option><a-select-option value="course_query">course_query</a-select-option><a-select-option value="catalog">catalog</a-select-option><a-select-option value="units">units</a-select-option><a-select-option value="progress">progress</a-select-option></a-select></a-col>
|
||||
<a-col :span="2"><a-input v-model:value="s.stepKey" size="small" placeholder="键名" /></a-col>
|
||||
<a-col :span="2"><a-input v-model:value="s.displayName" size="small" placeholder="名称" /></a-col>
|
||||
<a-col :span="2"><a-select v-model:value="s.stepType" size="small"><a-select-option value="http_request">http</a-select-option><a-select-option value="session_passthrough">passthrough</a-select-option><a-select-option value="browser_challenge">browser</a-select-option></a-select></a-col>
|
||||
<a-col :span="2"><a-input v-model:value="s.httpMethod" size="small" /></a-col>
|
||||
<a-col :span="2"><a-input-number v-model:value="s.displayOrder" :min="1" size="small" /></a-col>
|
||||
<a-col :span="6"><a-input v-model:value="s.urlTemplate" size="small" placeholder="URL 模板" /></a-col>
|
||||
<a-col :span="3">
|
||||
<a-checkbox v-model:checked="s.isEnabled">启用</a-checkbox>
|
||||
<a-button type="text" danger size="small" @click="editor.steps.splice(i, 1)">删</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.queryTemplateJson" size="small" :rows="1" placeholder="Query JSON" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.headersTemplateJson" size="small" :rows="1" placeholder="Headers JSON" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.bodyTemplateJson" size="small" :rows="1" placeholder="Body JSON" /></a-col>
|
||||
<a-col :span="6"><a-input v-model:value="s.successPath" size="small" placeholder="Success Path" /></a-col>
|
||||
<a-col :span="6"><a-input v-model:value="s.successExpectedValue" size="small" placeholder="Expected" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.outputCookiesText" size="small" :rows="1" placeholder="Output Cookies" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.outputVariablesText" size="small" :rows="1" placeholder="Output Vars" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.courseOptionMappingText" size="small" :rows="1" placeholder="课程映射" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.catalogMappingText" size="small" :rows="1" placeholder="章节映射" /></a-col>
|
||||
<a-col :span="6"><a-textarea v-model:value="s.unitMappingText" size="small" :rows="1" placeholder="资源映射" /></a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
<a-button type="primary" :loading="saving" @click="submit" block>
|
||||
{{ saving ? '保存中...' : editingPlatformId ? '保存平台配置' : '创建平台配置' }}
|
||||
</a-button>
|
||||
</a-card>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { getSystemSettings, updateSystemSettings } from '../../lib/api';
|
||||
import type { UpdateSystemSettingRequest } from '../../types/api';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const form = reactive<UpdateSystemSettingRequest>({
|
||||
systemName: 'UOOC Progress',
|
||||
registrationMode: 'open',
|
||||
allowMockFallback: false,
|
||||
browserChallengeTimeoutSeconds: 600,
|
||||
connectionEncryptionVersion: 1,
|
||||
defaultPlatformVisibility: 'active_only',
|
||||
requireEmailVerification: false,
|
||||
smtpHost: null,
|
||||
smtpPort: 587,
|
||||
smtpUseSsl: true,
|
||||
smtpUsername: null,
|
||||
smtpPassword: null,
|
||||
smtpFromEmail: null,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const s = await getSystemSettings();
|
||||
form.systemName = s.systemName;
|
||||
form.registrationMode = s.registrationMode;
|
||||
form.allowMockFallback = s.allowMockFallback;
|
||||
form.browserChallengeTimeoutSeconds = s.browserChallengeTimeoutSeconds;
|
||||
form.connectionEncryptionVersion = s.connectionEncryptionVersion;
|
||||
form.defaultPlatformVisibility = s.defaultPlatformVisibility;
|
||||
form.requireEmailVerification = s.requireEmailVerification;
|
||||
form.smtpHost = s.smtpHost;
|
||||
form.smtpPort = s.smtpPort;
|
||||
form.smtpUseSsl = s.smtpUseSsl;
|
||||
form.smtpUsername = s.smtpUsername;
|
||||
form.smtpFromEmail = s.smtpFromEmail;
|
||||
form.smtpPassword = null; // never pre-fill from server
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
|
||||
finally { loading.value = false; }
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateSystemSettings({ ...form });
|
||||
message.success('已保存。');
|
||||
form.smtpPassword = null;
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '保存失败。'); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="系统设置" :loading="loading">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 12 }" style="max-width: 720px;">
|
||||
<!-- System name -->
|
||||
<a-form-item label="系统名称">
|
||||
<a-input v-model:value="form.systemName" placeholder="UOOC Progress" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left" plain>注册</a-divider>
|
||||
|
||||
<a-form-item label="注册模式">
|
||||
<a-select v-model:value="form.registrationMode">
|
||||
<a-select-option value="open">开放注册</a-select-option>
|
||||
<a-select-option value="invite_only">仅邀请码注册</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="默认平台可见性">
|
||||
<a-select v-model:value="form.defaultPlatformVisibility">
|
||||
<a-select-option value="active_only">仅显示启用的平台</a-select-option>
|
||||
<a-select-option value="all">显示全部平台</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Mock 数据回退">
|
||||
<a-switch v-model:checked="form.allowMockFallback" />
|
||||
<span style="margin-left: 8px;">上游接口异常时回退到 mock 数据</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left" plain>邮箱验证</a-divider>
|
||||
|
||||
<a-form-item label="启用邮箱验证">
|
||||
<a-switch v-model:checked="form.requireEmailVerification" />
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="form.requireEmailVerification">
|
||||
<a-form-item label="SMTP 服务器" required>
|
||||
<a-input v-model:value="form.smtpHost" placeholder="例如 smtp.qq.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="端口">
|
||||
<a-input-number v-model:value="form.smtpPort" :min="1" :max="65535" />
|
||||
</a-form-item>
|
||||
<a-form-item label="使用 SSL">
|
||||
<a-switch v-model:checked="form.smtpUseSsl" />
|
||||
</a-form-item>
|
||||
<a-form-item label="发件邮箱" required>
|
||||
<a-input v-model:value="form.smtpFromEmail" placeholder="例如 noreply@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="SMTP 账号">
|
||||
<a-input v-model:value="form.smtpUsername" placeholder="留空则无需认证" />
|
||||
</a-form-item>
|
||||
<a-form-item label="SMTP 密码">
|
||||
<a-input-password v-model:value="form.smtpPassword" placeholder="留空则不修改" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="left" plain>其他</a-divider>
|
||||
|
||||
<a-form-item label="挑战超时秒数">
|
||||
<a-input-number v-model:value="form.browserChallengeTimeoutSeconds" :min="30" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="连接加密版本">
|
||||
<a-input-number v-model:value="form.connectionEncryptionVersion" :min="1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ offset: 6 }">
|
||||
<a-button type="primary" :loading="saving" @click="submit">{{ saving ? '保存中...' : '保存设置' }}</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
getAdminBrushTasks, adminStopBrushTask, adminStopAllBrushTasks,
|
||||
getAdminBrushConfig, updateAdminBrushConfig,
|
||||
} from '../../lib/api';
|
||||
import type { AdminBrushTaskDto } from '../../lib/api';
|
||||
|
||||
const tasks = ref<AdminBrushTaskDto[]>([]);
|
||||
const pauseNew = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => { await refresh(); timer = setInterval(refresh, 3000); });
|
||||
onUnmounted(() => { if (timer) clearInterval(timer); });
|
||||
|
||||
async function refresh() {
|
||||
try { tasks.value = await getAdminBrushTasks(); } catch { /* ignore */ }
|
||||
try { const c = await getAdminBrushConfig(); pauseNew.value = c.pauseNewTasks; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function stopOne(userId: number) {
|
||||
try { await adminStopBrushTask(userId); message.success('已停止。'); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); }
|
||||
}
|
||||
|
||||
async function stopAll() {
|
||||
try { await adminStopAllBrushTasks(); message.success('已停止全部任务。'); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); }
|
||||
}
|
||||
|
||||
async function togglePause() {
|
||||
pauseNew.value = !pauseNew.value;
|
||||
try { await updateAdminBrushConfig({ pauseNewTasks: pauseNew.value }); message.success(pauseNew.value ? '已暂停接收新任务。' : '已恢复接收新任务。'); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '失败。'); pauseNew.value = !pauseNew.value; }
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '进度', key: 'progress', width: 100 },
|
||||
{ title: '章节', dataIndex: 'currentChapterName', ellipsis: true },
|
||||
{ title: '小节', dataIndex: 'currentSectionName', ellipsis: true },
|
||||
{ title: '资源', dataIndex: 'currentVideoTitle', ellipsis: true },
|
||||
{ title: '视频位置', key: 'pos', width: 100 },
|
||||
{ title: '错误', dataIndex: 'lastError', ellipsis: true, width: 150 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="任务监控">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="togglePause" :type="pauseNew ? 'primary' : 'default'" danger>
|
||||
{{ pauseNew ? '已暂停新任务' : '暂停新任务' }}
|
||||
</a-button>
|
||||
<a-button danger @click="stopAll" :disabled="tasks.length === 0">停止全部</a-button>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-empty v-if="tasks.length === 0" description="暂无运行中的刷课任务。" style="padding: 24px 0;" />
|
||||
|
||||
<a-table v-else :data-source="tasks" :columns="columns" row-key="userId" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'Running' ? 'processing' : record.status === 'Completed' ? 'success' : record.status === 'Failed' ? 'error' : 'default'">
|
||||
{{ record.status === 'Running' ? '进行中' : record.status === 'Completed' ? '完成' : record.status === 'Failed' ? '失败' : record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
{{ record.completedVideos }}/{{ record.totalVideos }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'pos'">
|
||||
{{ Math.round(record.currentVideoPos) }}s / {{ Math.round(record.currentVideoLength) }}s
|
||||
<a-tag v-if="record.retryCount > 0" :color="record.retryCount >= 3 ? 'error' : 'warning'" style="margin-left: 4px;">
|
||||
{{ record.retryCount }}/3
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" danger :disabled="record.status !== 'Running'" @click="stopOne(record.userId)">停止</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { getAdminUsers, updateAdminUser } from '../../lib/api';
|
||||
import type { AuthUserDto } from '../../types/api';
|
||||
|
||||
const users = ref<AuthUserDto[]>([]);
|
||||
const loading = ref(false);
|
||||
const savingId = ref<number | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try { users.value = await getAdminUsers(); }
|
||||
catch (err) { message.error(err instanceof Error ? err.message : '加载失败。'); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function save(u: AuthUserDto) {
|
||||
savingId.value = u.id;
|
||||
try {
|
||||
const updated = await updateAdminUser(u.id, { displayName: u.displayName, role: u.role, status: u.status });
|
||||
users.value = users.value.map(x => x.id === updated.id ? updated : x);
|
||||
message.success(`已更新 ${updated.displayName}。`);
|
||||
} catch (err) { message.error(err instanceof Error ? err.message : '更新失败。'); }
|
||||
finally { savingId.value = null; }
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{ title: '显示名', dataIndex: 'displayName', key: 'displayName' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '创建时间', key: 'createdAt' },
|
||||
{ title: '最近登录', key: 'lastLoginAt' },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="用户管理">
|
||||
<template #extra>
|
||||
<a-button @click="load" :loading="loading">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :data-source="users" :columns="columns" :loading="loading" row-key="id" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'displayName'">
|
||||
<a-input v-model:value="record.displayName" size="small" style="width: 120px;" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<a-select v-model:value="record.role" size="small" style="width: 100px;">
|
||||
<a-select-option value="user">普通用户</a-select-option>
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-select v-model:value="record.status" size="small" style="width: 80px;">
|
||||
<a-select-option value="active">启用</a-select-option>
|
||||
<a-select-option value="disabled">停用</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ new Date(record.createdAt).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'lastLoginAt'">
|
||||
{{ record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="primary" size="small" :loading="savingId === record.id" @click="save(record)">
|
||||
{{ savingId === record.id ? '...' : '保存' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const form = ref({ username: '', password: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
if (!form.value.username.trim() || !form.value.password.trim()) {
|
||||
message.error('请输入用户名和密码。');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login(form.value.username, form.value.password);
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/courses';
|
||||
await router.replace(redirect);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh; background: #f0f2f5;">
|
||||
<a-layout-content style="display: flex; align-items: center; justify-content: center; padding: 16px;">
|
||||
<div style="width: 100%; max-width: 400px;">
|
||||
<a-flex vertical align="center" style="margin-bottom: 32px;">
|
||||
<a-typography-title :level="2" style="margin-bottom: 4px;">{{ authStore.systemName }}</a-typography-title>
|
||||
<a-typography-text type="secondary">学习进度管理平台</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-card>
|
||||
<a-typography-title :level="4" style="margin-bottom: 24px; text-align: center;">
|
||||
系统登录
|
||||
</a-typography-title>
|
||||
|
||||
<a-alert
|
||||
v-if="authStore.authError"
|
||||
type="error"
|
||||
:message="authStore.authError"
|
||||
closable
|
||||
style="margin-bottom: 24px;"
|
||||
/>
|
||||
|
||||
<a-form :model="form" @finish="submit">
|
||||
<a-form-item name="username" :rules="[{ required: true, message: '请输入用户名' }]">
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
size="large"
|
||||
placeholder="用户名"
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password" :rules="[{ required: true, message: '请输入密码' }]">
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="密码"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-flex justify="center">
|
||||
<RouterLink to="/register">
|
||||
<a-button type="link">还没有账号?去注册</a-button>
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { MailOutlined, UserOutlined, SmileOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { sendEmailCode } from '../../lib/api';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const form = ref({ username: '', displayName: '', password: '', inviteCode: '', email: '', emailCode: '' });
|
||||
const loading = ref(false);
|
||||
const sendingCode = ref(false);
|
||||
const needInviteCode = computed(() => authStore.registrationMode === 'invite_only');
|
||||
|
||||
async function handleSendCode() {
|
||||
if (!form.value.email.trim()) { message.warning('请先输入邮箱。'); return; }
|
||||
sendingCode.value = true;
|
||||
try {
|
||||
await sendEmailCode(form.value.email.trim());
|
||||
message.success('验证码已发送。');
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '发送失败。');
|
||||
} finally { sendingCode.value = false; }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.register({
|
||||
username: form.value.username.trim(),
|
||||
displayName: form.value.displayName.trim(),
|
||||
password: form.value.password,
|
||||
inviteCode: form.value.inviteCode.trim() || undefined,
|
||||
email: form.value.email.trim() || undefined,
|
||||
emailCode: form.value.emailCode.trim() || undefined,
|
||||
});
|
||||
await router.replace('/courses');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh; background: #f0f2f5;">
|
||||
<a-layout-content style="display: flex; align-items: center; justify-content: center; padding: 16px;">
|
||||
<div style="width: 100%; max-width: 440px;">
|
||||
<a-flex vertical align="center" style="margin-bottom: 32px;">
|
||||
<a-typography-title :level="2" style="margin-bottom: 4px;">{{ authStore.systemName }}</a-typography-title>
|
||||
<a-typography-text type="secondary">创建新账号</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-card>
|
||||
<a-typography-title :level="4" style="margin-bottom: 24px; text-align: center;">
|
||||
注册账号
|
||||
</a-typography-title>
|
||||
|
||||
<a-alert
|
||||
v-if="authStore.authError"
|
||||
type="error"
|
||||
:message="authStore.authError"
|
||||
closable
|
||||
style="margin-bottom: 24px;"
|
||||
/>
|
||||
|
||||
<a-form :model="form" @finish="submit">
|
||||
<a-form-item name="username" :rules="[{ required: true, min: 3, message: '用户名至少 3 位' }]">
|
||||
<a-input v-model:value="form.username" size="large" placeholder="用户名">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item name="displayName" :rules="[{ required: true, message: '请输入显示名' }]">
|
||||
<a-input v-model:value="form.displayName" size="large" placeholder="显示名">
|
||||
<template #prefix><SmileOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item name="password" :rules="[{ required: true, min: 6, message: '密码至少 6 位' }]">
|
||||
<a-input-password v-model:value="form.password" size="large" placeholder="密码(至少 6 位)">
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Email + verification code -->
|
||||
<a-form-item
|
||||
v-if="authStore.requireEmailVerification"
|
||||
name="email"
|
||||
:rules="[{ required: authStore.requireEmailVerification, type: 'email', message: '请输入有效邮箱' }]"
|
||||
>
|
||||
<a-input v-model:value="form.email" size="large" placeholder="邮箱地址">
|
||||
<template #prefix><MailOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="authStore.requireEmailVerification" name="emailCode" :rules="[{ required: true, message: '请输入验证码' }]">
|
||||
<a-input-search
|
||||
v-model:value="form.emailCode"
|
||||
size="large"
|
||||
placeholder="邮箱验证码"
|
||||
:loading="sendingCode"
|
||||
@search="handleSendCode"
|
||||
>
|
||||
<template #enterButton>发送验证码</template>
|
||||
</a-input-search>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="inviteCode" :rules="needInviteCode ? [{ required: true, message: '当前注册模式需要邀请码' }] : []">
|
||||
<a-input v-model:value="form.inviteCode" size="large" placeholder="邀请码(选填)" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
|
||||
注册并进入后台
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-flex justify="center">
|
||||
<RouterLink to="/login">
|
||||
<a-button type="link">已有账号?去登录</a-button>
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5088',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user