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:
2026-06-24 15:31:32 +08:00
co-authored by Claude Fable 5
commit cd486a36ef
84 changed files with 16242 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5088
+12
View File
@@ -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>
+1938
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+18
View File
@@ -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>
+110
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+152
View File
@@ -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>
+402
View File
@@ -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;
}
+60
View File
@@ -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));
}
+23
View File
@@ -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();
+202
View File
@@ -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;
+155
View File
@@ -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 : '请求失败,请稍后重试。';
},
},
});
+144
View File
@@ -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;
},
},
});
+249
View File
@@ -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;
},
},
});
+513
View File
@@ -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;
}
+45
View File
@@ -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>
+314
View File
@@ -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>
+13
View File
@@ -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>
+27
View File
@@ -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>
+205
View File
@@ -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>
+102
View File
@@ -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>
+91
View File
@@ -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>
+125
View File
@@ -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>
+23
View File
@@ -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"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -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,
},
});