+
+
+
-
-
+
+
-
-
+
- {{ countdown }}s后重新获取
+ {{ countdownText }}
-
-
- {{ props.error }}
-
-
+
+
+ {{ error }}
+
diff --git a/src/components/basic/MyButton.vue b/src/components/basic/MyButton.vue
index 55be0f2..27235de 100644
--- a/src/components/basic/MyButton.vue
+++ b/src/components/basic/MyButton.vue
@@ -5,18 +5,18 @@ const props = defineProps({
type: {
type: String,
default: 'default',
- validator: (val) => {
- return ['default', 'primary', 'success', 'warning', 'danger'].includes(
- val
- )
- }
+ validator: (value) =>
+ ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
},
size: {
type: String,
default: 'medium',
- validator: (val) => {
- return ['small', 'medium', 'large'].includes(val)
- }
+ validator: (value) => ['small', 'medium', 'large'].includes(value)
+ },
+ nativeType: {
+ type: String,
+ default: 'button',
+ validator: (value) => ['button', 'submit', 'reset'].includes(value)
},
disabled: {
type: Boolean,
@@ -25,69 +25,164 @@ const props = defineProps({
loading: {
type: Boolean,
default: false
+ },
+ ariaLabel: {
+ type: String,
+ default: ''
}
})
-const buttonClass = computed(() => {
- const baseClass = 'mx-1 rounded-full transition-all duration-300 ease-in-out'
- const typeClass = {
- default:
- 'bg-gray-500 text-white hover:ring hover:ring-gray-800 hover:ring-opacity-50',
- primary:
- 'bg-blue-200 text-white hover:ring hover:ring-blue-800 hover:ring-opacity-50',
- success:
- 'bg-green-500 text-white hover:ring hover:ring-green-800 hover:ring-opacity-50',
- warning:
- 'bg-yellow-500 text-white hover:ring hover:ring-yellow-800 hover:ring-opacity-50',
- danger:
- 'bg-red-300 text-white hover:ring hover:ring-red-800 hover:ring-opacity-50'
- }[props.type]
-
- const sizeClass = {
- small: 'w-12 h-6',
- medium: 'text-base',
- large: 'w-16 h-8'
- }[props.size]
-
- return `${baseClass} ${typeClass} ${sizeClass} ${
- props.disabled ? 'cursor-not-allowed opacity-50' : ''
- }`
-})
-
-//给组件定义一个click事件
const emit = defineEmits(['click'])
+const buttonClass = computed(() => [
+ 'app-button',
+ `app-button--${props.type}`,
+ `app-button--${props.size}`
+])
+
const handleClick = (event) => {
- if (props.disabled || props.loading) {
- return
- }
+ if (props.disabled || props.loading) return
emit('click', event)
}
+
-
diff --git a/src/components/didi/DidiClassBox.vue b/src/components/didi/DidiClassBox.vue
index 5ba381d..bf9d876 100644
--- a/src/components/didi/DidiClassBox.vue
+++ b/src/components/didi/DidiClassBox.vue
@@ -2,7 +2,7 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStudentPersonStore } from '@/stores/modules/studentPerson'
-import studentAvatar from '@/assets/student/avatar.png'
+import studentAvatar from '@/assets/student/avatar-default.svg'
import teacherAvatar from '@/assets/icon/teacher.png'
const studentPersonStore = useStudentPersonStore()
diff --git a/src/components/public/BrandMark.vue b/src/components/public/BrandMark.vue
new file mode 100644
index 0000000..82b15da
--- /dev/null
+++ b/src/components/public/BrandMark.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+ {{ $t('public.brand.name') }}
+ {{ $t('public.brand.tagline') }}
+
+
+
+
+
diff --git a/src/components/public/EditorialCover.vue b/src/components/public/EditorialCover.vue
new file mode 100644
index 0000000..5bd52c9
--- /dev/null
+++ b/src/components/public/EditorialCover.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
![]()
+
+
+
+
+
+ {{ category }}
+
+
+
+
+
diff --git a/src/components/public/PublicAsyncState.vue b/src/components/public/PublicAsyncState.vue
new file mode 100644
index 0000000..67c37d1
--- /dev/null
+++ b/src/components/public/PublicAsyncState.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+ {{ state === 'error' ? '!' : '—' }}
+
+
+
{{ title }}
+
{{ description }}
+
+
+ {{ actionLabel }}
+
+
+
+
+
+
diff --git a/src/components/public/PublicCourseCard.vue b/src/components/public/PublicCourseCard.vue
new file mode 100644
index 0000000..baf8d1e
--- /dev/null
+++ b/src/components/public/PublicCourseCard.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+ {{ levelLabel }}
+
+
+
+
{{ course.category }}
+
+
+ {{ course.title || t('public.course.untitled') }}
+
+
+
+ {{ course.summary || t('public.course.summaryPending') }}
+
+
+
+
+
- {{ t('public.course.teacher') }}
+ - {{ course.teacher?.displayName || '—' }}
+
+
+
- {{ t('public.course.duration') }}
+ -
+ {{
+ t('public.course.minutes', {
+ count: Number(course.durationMinutes) || 0
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/public/PublicShell.vue b/src/components/public/PublicShell.vue
new file mode 100644
index 0000000..5c0d9c7
--- /dev/null
+++ b/src/components/public/PublicShell.vue
@@ -0,0 +1,586 @@
+
+
+
+
+
+
+
diff --git a/src/components/public/PublicTeacherCard.vue b/src/components/public/PublicTeacherCard.vue
new file mode 100644
index 0000000..072ea6c
--- /dev/null
+++ b/src/components/public/PublicTeacherCard.vue
@@ -0,0 +1,363 @@
+
+
+
+
+
+
+
+ {{ teacher.bio || t('public.teacher.bioPending') }}
+
+
+
+ {{ item }}
+
+
+
+
+
- {{ t('public.teacher.experience') }}
+ -
+ {{
+ t('public.teacher.years', {
+ count: Number(teacher.experienceYears) || 0
+ })
+ }}
+
+
+
+
- {{ t('public.teacher.courses') }}
+ - {{ Number(teacher.publishedCourseCount) || 0 }}
+
+
+
- {{ t('public.teacher.languages') }}
+ - {{ languages.length ? languages.join(' · ') : '—' }}
+
+
+
+
+
+
+
+
diff --git a/src/components/public/usePublicMeta.js b/src/components/public/usePublicMeta.js
new file mode 100644
index 0000000..989aa6e
--- /dev/null
+++ b/src/components/public/usePublicMeta.js
@@ -0,0 +1,39 @@
+import { watchEffect } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+function ensureMeta(selector, attributes) {
+ let element = document.head.querySelector(selector)
+ if (!element) {
+ element = document.createElement('meta')
+ Object.entries(attributes).forEach(([key, value]) => {
+ element.setAttribute(key, value)
+ })
+ document.head.appendChild(element)
+ }
+ return element
+}
+
+export function usePublicMeta(titleKey, descriptionKey) {
+ const { t, locale } = useI18n()
+
+ watchEffect(() => {
+ if (typeof document === 'undefined') return
+
+ const title = t(titleKey)
+ const description = t(descriptionKey)
+ const brand = t('public.brand.name')
+
+ document.documentElement.lang = locale.value === 'zh' ? 'zh-CN' : 'en'
+ document.title = title === brand ? title : `${title} · ${brand}`
+
+ ensureMeta('meta[name="description"]', {
+ name: 'description'
+ }).setAttribute('content', description)
+ ensureMeta('meta[property="og:title"]', {
+ property: 'og:title'
+ }).setAttribute('content', document.title)
+ ensureMeta('meta[property="og:description"]', {
+ property: 'og:description'
+ }).setAttribute('content', description)
+ })
+}
diff --git a/src/components/service/LanguageToggle.vue b/src/components/service/LanguageToggle.vue
index edd6cfb..02b4c16 100644
--- a/src/components/service/LanguageToggle.vue
+++ b/src/components/service/LanguageToggle.vue
@@ -1,10 +1,12 @@
-
-
-
-
-
+
+
+ 中文
+
+
+ EN
+
+
-
+
diff --git a/src/components/service/logoComponent.vue b/src/components/service/logoComponent.vue
index e78efe8..6581567 100644
--- a/src/components/service/logoComponent.vue
+++ b/src/components/service/logoComponent.vue
@@ -1,12 +1,124 @@
-
+
-
-

-

-
+
+
+
+ 国际中文教育
+ International Chinese
+
+
+
+
diff --git a/src/i18n/catalog.test.js b/src/i18n/catalog.test.js
new file mode 100644
index 0000000..7ded12c
--- /dev/null
+++ b/src/i18n/catalog.test.js
@@ -0,0 +1,62 @@
+import { createI18n } from 'vue-i18n'
+import { describe, expect, it } from 'vitest'
+
+import en from '@/i18n/locales/en.json'
+import zh from '@/i18n/locales/zh.json'
+
+const flattenKeys = (value, prefix = '') =>
+ Object.entries(value).flatMap(([key, child]) => {
+ const path = prefix ? `${prefix}.${key}` : key
+ return child && typeof child === 'object'
+ ? flattenKeys(child, path)
+ : [path]
+ })
+
+const interpolation = {
+ count: 1,
+ course: 'Course',
+ date: '2030-01-01',
+ due: '2030-01-01',
+ email: 'learner@example.com',
+ learners: 1,
+ minutes: 60,
+ name: 'Learner',
+ page: 1,
+ publishedCourses: 1,
+ results: 1,
+ seconds: 60,
+ teacher: 'Teacher',
+ time: '10:00',
+ timezone: 'Asia/Shanghai',
+ title: 'Title',
+ topic: 'Topic',
+ total: 1,
+ years: 1
+}
+
+describe('i18n catalogue', () => {
+ it('keeps the English and Chinese catalogue structures identical', () => {
+ expect(flattenKeys(en).sort()).toEqual(flattenKeys(zh).sort())
+ })
+
+ it.each([
+ ['en', en],
+ ['zh', zh]
+ ])(
+ 'compiles every %s message without runtime syntax errors',
+ (locale, messages) => {
+ const i18n = createI18n({
+ legacy: false,
+ locale,
+ fallbackLocale: 'en',
+ messages: { en, zh }
+ })
+
+ for (const key of flattenKeys(messages)) {
+ expect(() => i18n.global.t(key, interpolation), key).not.toThrow()
+ }
+
+ expect(i18n.global.t('auth.placeholders.email')).toBe('you@example.com')
+ }
+ )
+})
diff --git a/src/i18n/index.js b/src/i18n/index.js
index 8287fa7..14f53d1 100644
--- a/src/i18n/index.js
+++ b/src/i18n/index.js
@@ -2,16 +2,39 @@ import { createI18n } from 'vue-i18n'
import zh from '@/i18n/locales/zh.json'
import en from '@/i18n/locales/en.json'
-const messages = {
- zh,
- en
+export const supportedLocales = ['zh', 'en']
+
+export function normalizeLocale(value) {
+ const locale = String(value || '').toLowerCase()
+ return locale.startsWith('zh') ? 'zh' : locale.startsWith('en') ? 'en' : null
+}
+
+export function detectInitialLocale() {
+ if (typeof window === 'undefined') return 'en'
+
+ try {
+ const stored = normalizeLocale(window.localStorage.getItem('lang'))
+ if (stored) return stored
+ } catch {
+ // Storage may be unavailable in privacy mode; browser detection still works.
+ }
+
+ const candidates = Array.isArray(window.navigator.languages)
+ ? window.navigator.languages
+ : [window.navigator.language]
+ return candidates.some((locale) => normalizeLocale(locale) === 'zh')
+ ? 'zh'
+ : 'en'
}
+const initialLocale = detectInitialLocale()
+
const i18n = createI18n({
legacy: false,
globalInjection: true,
- locale: 'zh', // 默认语言
- messages
+ locale: initialLocale,
+ fallbackLocale: 'en',
+ messages: { zh, en }
})
export default i18n
diff --git a/src/i18n/index.test.js b/src/i18n/index.test.js
new file mode 100644
index 0000000..c3c15ec
--- /dev/null
+++ b/src/i18n/index.test.js
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { detectInitialLocale, normalizeLocale } from './index'
+
+describe('locale detection', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ })
+
+ it('normalizes supported locale variants', () => {
+ expect(normalizeLocale('zh-CN')).toBe('zh')
+ expect(normalizeLocale('en-US')).toBe('en')
+ expect(normalizeLocale('fr-FR')).toBeNull()
+ })
+
+ it('prefers a saved locale over browser languages', () => {
+ window.localStorage.setItem('lang', 'zh')
+ Object.defineProperty(window.navigator, 'languages', {
+ configurable: true,
+ value: ['en-US']
+ })
+
+ expect(detectInitialLocale()).toBe('zh')
+ })
+
+ it('uses English for a first visit outside a Chinese browser', () => {
+ Object.defineProperty(window.navigator, 'languages', {
+ configurable: true,
+ value: ['fr-FR', 'en-US']
+ })
+
+ expect(detectInitialLocale()).toBe('en')
+ })
+
+ it('uses Chinese when any preferred browser language is Chinese', () => {
+ Object.defineProperty(window.navigator, 'languages', {
+ configurable: true,
+ value: ['en-US', 'zh-CN']
+ })
+
+ expect(detectInitialLocale()).toBe('zh')
+ })
+})
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 17ef7bd..bc1c5ef 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -7,6 +7,708 @@
"submit": "Submit",
"cancel": "Cancel"
},
- "login": "Login",
- "register": "Register"
+ "login": "Sign in",
+ "register": "Register",
+ "common": {
+ "skipToContent": "Skip to main content",
+ "requestFailed": "The request could not be completed. Please try again."
+ },
+ "a11y": {
+ "skipToContent": "Skip to main content"
+ },
+ "system": {
+ "errorBoundary": {
+ "eyebrow": "Something went wrong",
+ "title": "This page could not be displayed",
+ "description": "Your data is safe. Reload the page to try again, or return later if the problem continues.",
+ "reload": "Reload page"
+ }
+ },
+ "shell": {
+ "brandHome": "Go to your workspace home",
+ "menu": {
+ "open": "Open navigation menu",
+ "close": "Close navigation menu",
+ "drawer": "Workspace navigation"
+ },
+ "account": "Account",
+ "notifications": "Notifications",
+ "profile": "Profile",
+ "logout": "Sign out",
+ "loggingOut": "Signing out…"
+ },
+ "roles": {
+ "student": {
+ "label": "Student"
+ },
+ "teacher": {
+ "label": "Teacher"
+ },
+ "administrator": {
+ "label": "Administrator"
+ }
+ },
+ "nav": {
+ "student": {
+ "home": "Home",
+ "teachers": "Teachers",
+ "courses": "Courses",
+ "conversation": "Chinese practice"
+ },
+ "teacher": {
+ "home": "Home",
+ "requests": "Teaching requests",
+ "courses": "Courses"
+ },
+ "administrator": {
+ "operations": "Operations",
+ "reviews": "Reviews",
+ "data": "Data"
+ }
+ },
+ "teacher": {
+ "account": {
+ "profile": "Profile",
+ "security": "Security",
+ "notices": "Notifications",
+ "navigationLabel": "Teacher account settings"
+ }
+ },
+ "auth": {
+ "brandShort": "International Chinese",
+ "brandEnglish": "Learning Platform",
+ "brandHomeLabel": "Go to the public homepage",
+ "modeNavigation": "Sign-in and registration",
+ "legalNavigation": "Legal information",
+ "eyebrow": "Learn beyond the textbook",
+ "heroTitle": "Make Chinese part of your real life.",
+ "heroDescription": "Meet verified teachers, choose a learning path that fits your goals, and practise Chinese in situations that matter.",
+ "quoteCaption": "A thoughtful learning path begins with the right teacher.",
+ "loginEyebrow": "Welcome back",
+ "loginTitle": "Continue your learning",
+ "loginDescription": "Sign in to return to your courses, bookings and conversations.",
+ "roleLegend": "Choose your role",
+ "recoveryLink": "Need help signing in?",
+ "noAccount": "New to the platform?",
+ "haveAccount": "Already have an account?",
+ "betaLabel": "Public Beta",
+ "terms": "Terms of Service",
+ "privacy": "Privacy Notice",
+ "trust": {
+ "verified": "Verified teacher profiles",
+ "firstParty": "Secure first-party sessions",
+ "realLearning": "Learning built around real situations"
+ },
+ "roles": {
+ "student": "Student",
+ "teacher": "Teacher",
+ "administrator": "Administrator"
+ },
+ "fields": {
+ "email": "Email address",
+ "password": "Password",
+ "verificationCode": "Verification code",
+ "confirmPassword": "Confirm password",
+ "name": "Full name",
+ "nationality": "Nationality",
+ "region": "Country or region",
+ "age": "Age"
+ },
+ "placeholders": {
+ "email": "you{'@'}example.com",
+ "password": "Enter your password",
+ "fullEmail": "Enter your complete email address",
+ "verificationCode": "Enter the 6-digit code",
+ "newPassword": "Create a strong password",
+ "confirmPassword": "Enter the password again",
+ "name": "How should we address you?",
+ "nationality": "e.g. Canadian",
+ "region": "Where do you currently live?",
+ "age": "Your age"
+ },
+ "actions": {
+ "login": "Sign in",
+ "createAccount": "Create account",
+ "sendCode": "Send code",
+ "sendingCode": "Sending…",
+ "next": "Continue",
+ "back": "Back"
+ },
+ "messages": {
+ "loginSuccess": "Welcome back.",
+ "registerSuccess": "Your account has been created.",
+ "codeSent": "Verification code sent. Check your inbox.",
+ "codeSentDevelopment": "Verification code generated for this development environment.",
+ "roleRedirected": "This action is for student accounts. We have taken you to your workspace instead."
+ },
+ "errors": {
+ "emailRequired": "Enter your email address.",
+ "emailInvalid": "Enter a valid email address.",
+ "passwordRequired": "Enter your password.",
+ "passwordStrength": "Use at least 8 characters with uppercase, lowercase and a number.",
+ "passwordConfirmationRequired": "Enter your password again.",
+ "passwordMismatch": "The passwords do not match.",
+ "invalidProfile": "We could not load your account profile.",
+ "loginFailed": "Sign-in failed. Check your details and try again.",
+ "registerFailed": "We could not create your account. Please try again.",
+ "roleRequired": "Choose whether you are registering as a student or teacher.",
+ "codeInvalid": "Enter the complete verification code.",
+ "codeSendFailed": "The code could not be sent. You can retry now.",
+ "termsRequired": "Please agree to the Terms of Service and Privacy Notice.",
+ "nameRequired": "Enter your name.",
+ "nationalityRequired": "Enter your nationality.",
+ "regionRequired": "Enter your country or region.",
+ "ageInvalid": "Enter an age between 6 and 120.",
+ "levelRequired": "Choose your current Chinese level.",
+ "teacherReviewRequired": "Confirm that you understand the teacher review process."
+ },
+ "register": {
+ "eyebrow": "Create your account",
+ "title": "Start with a learning profile that fits you",
+ "description": "Three short steps help us prepare the right workspace. You can go back at any time.",
+ "progressLabel": "Registration progress",
+ "accountTitle": "Secure your account",
+ "accountDescription": "Choose a role and verify the email you will use to sign in.",
+ "profileTitle": "Tell us who you are",
+ "profileDescription": "We use these details to personalise your experience and support your account.",
+ "studentStepTitle": "Shape your learning path",
+ "studentStepDescription": "Choose your current level and tell teachers what you hope to achieve.",
+ "teacherStepTitle": "Prepare your teacher application",
+ "teacherStepDescription": "Tell us about relevant qualifications. An administrator will review your profile before it becomes public.",
+ "passwordHint": "Use at least 8 characters, including uppercase and lowercase letters and a number.",
+ "agreePrefix": "I agree to the",
+ "and": "and",
+ "codeCountdown": "Send again in {seconds}s",
+ "codeSentTo": "Code sent to {email}",
+ "codeValidity": "The code is valid for 10 minutes.",
+ "privacyNoteTitle": "Why we ask",
+ "privacyNote": "These details support account safety, teacher matching and platform operations. They are handled as described in our Privacy Notice.",
+ "levelLegend": "Current Chinese level",
+ "learningGoalLabel": "What would you like to do in Chinese?",
+ "learningGoalPlaceholder": "For example: speak confidently at work, prepare for HSK 4, or talk with family.",
+ "certificateLegend": "Relevant qualifications",
+ "certificateHelp": "Select qualifications you hold. This declaration is not a document upload and does not mean your profile is verified.",
+ "teacherReviewTitle": "Teacher profiles are reviewed",
+ "teacherReviewDescription": "Your profile will remain private until an administrator reviews the information you submit. We may contact you for supporting documents.",
+ "teacherReviewAcknowledgement": "I understand that declaring a qualification does not complete verification.",
+ "steps": {
+ "account": "Account",
+ "profile": "Profile",
+ "learning": "Learning"
+ },
+ "roleDescriptions": {
+ "student": "Find teachers, book lessons and manage your learning.",
+ "teacher": "Apply to teach and manage courses after verification."
+ },
+ "levels": {
+ "beginner": "I am completely new to Chinese.",
+ "hsk1": "I understand a few familiar words and simple phrases.",
+ "hsk2": "I can handle basic everyday exchanges.",
+ "hsk3": "I can communicate in many familiar situations.",
+ "hsk4": "I can discuss a broad range of everyday and study topics.",
+ "hsk5": "I can read, listen and communicate with good fluency.",
+ "hsk6": "I can understand and express complex ideas with confidence."
+ },
+ "certificates": {
+ "international": "Certificate for Teachers of Chinese to Speakers of Other Languages",
+ "tcsl": "Teaching Chinese as a Second Language qualification",
+ "ability": "Certificate of Ability in Teaching Chinese as a Foreign Language",
+ "mandarin": "Putonghua Proficiency Test certificate"
+ }
+ },
+ "session": {
+ "offline": "We could not reach the service. Check your connection and try signing in again.",
+ "expired": "Your session has expired. Sign in again to continue where you left off.",
+ "authenticationRequired": "Sign in to continue. We will return you to the page you requested."
+ },
+ "quote": "Across the world, a true friend still feels close."
+ },
+ "public": {
+ "a11y": {
+ "skipToContent": "Skip to main content"
+ },
+ "brand": {
+ "name": "International Chinese",
+ "tagline": "Learn for real life"
+ },
+ "language": {
+ "label": "Choose language"
+ },
+ "nav": {
+ "home": "Home",
+ "teachers": "Teachers",
+ "courses": "Courses",
+ "dashboard": "My workspace",
+ "signIn": "Sign in",
+ "createAccount": "Create account",
+ "primary": "Primary navigation",
+ "mobile": "Mobile navigation",
+ "toggleMenu": "Toggle navigation menu"
+ },
+ "footer": {
+ "mission": "A public Beta platform connecting Chinese learners with verified teachers and practical learning experiences.",
+ "explore": "Explore",
+ "legal": "Legal",
+ "support": "Account support",
+ "terms": "Terms of Service",
+ "privacy": "Privacy Notice",
+ "beta": "Public Beta · Features may change"
+ },
+ "common": {
+ "applyFilters": "Apply filters",
+ "clearFilters": "Clear filters",
+ "teacherLoadErrorTitle": "Teachers could not be loaded",
+ "courseLoadErrorTitle": "Courses could not be loaded",
+ "loadErrorBody": "Check your connection and try again. Other parts of the platform may still be available.",
+ "loadingTeachers": "Loading verified teachers…",
+ "loadingCourses": "Loading published courses…",
+ "retry": "Try again",
+ "pagination": "Pagination",
+ "previous": "Previous",
+ "next": "Next",
+ "pageOf": "Page {page} of {total}",
+ "breadcrumb": "Breadcrumb"
+ },
+ "teacher": {
+ "unnamed": "Teacher profile",
+ "verified": "Verified teacher",
+ "profilePending": "Professional profile coming soon",
+ "bioPending": "This teacher is still preparing their introduction.",
+ "experience": "Experience",
+ "years": "{count} years",
+ "courses": "Courses",
+ "languages": "Teaching languages",
+ "referenceRate": "Reference rate",
+ "priceOnRequest": "Ask the teacher",
+ "viewProfile": "View profile",
+ "viewNamed": "View {name}'s profile"
+ },
+ "course": {
+ "untitled": "Untitled course",
+ "summaryPending": "Course introduction coming soon.",
+ "teacher": "Teacher",
+ "duration": "Lesson length",
+ "minutes": "{count} min",
+ "referencePrice": "Reference price",
+ "free": "No fee listed",
+ "viewDetails": "View course",
+ "levels": {
+ "beginner": "Beginner",
+ "elementary": "Elementary",
+ "intermediate": "Intermediate",
+ "advanced": "Advanced",
+ "all": "All levels"
+ }
+ },
+ "home": {
+ "metaTitle": "Learn Chinese for real life",
+ "metaDescription": "Discover verified Chinese teachers and published courses, then continue from browsing to booking in one secure platform.",
+ "eyebrow": "International Chinese · Public Beta",
+ "title": "Learn Chinese for real life, with teachers you can trust.",
+ "lead": "Explore verified teacher profiles and practical courses before you create an account. When you are ready, continue straight to a booking request.",
+ "findTeacher": "Find a teacher",
+ "exploreCourses": "Explore courses",
+ "trustNote": "Browse first. Registration is only needed when you want to book or join a learning space.",
+ "stepsEyebrow": "A clear learning path",
+ "stepsTitle": "From curiosity to conversation in three steps",
+ "stepsLead": "Choose with real information, agree on a lesson with your teacher, and keep your learning work together.",
+ "stepOneTitle": "Discover",
+ "stepOneBody": "Compare verified profiles, teaching languages, specialities and published courses.",
+ "stepTwoTitle": "Request",
+ "stepTwoBody": "Send a booking request in your own time zone. The teacher confirms before a lesson is scheduled.",
+ "stepThreeTitle": "Learn",
+ "stepThreeBody": "Meet in class, complete assignments and continue practising Chinese between lessons.",
+ "teachersEyebrow": "People who teach",
+ "teachersTitle": "Meet verified teachers",
+ "teachersLead": "Every public profile belongs to an enabled teacher who has passed the platform review.",
+ "viewAllTeachers": "View all teachers",
+ "noTeachersTitle": "Our first teachers are preparing",
+ "noTeachersBody": "The production catalogue is intentionally honest: verified profiles will appear here as teachers complete review.",
+ "joinAsTeacher": "Apply as a teacher",
+ "coursesEyebrow": "Ways to learn",
+ "coursesTitle": "Explore published courses",
+ "coursesLead": "See lesson focus, level, suggested duration and reference price before deciding what fits.",
+ "viewAllCourses": "View all courses",
+ "noCoursesTitle": "Published courses are coming",
+ "noCoursesBody": "Verified teachers are preparing the first public course descriptions. No sample listings are shown as real inventory.",
+ "valuesEyebrow": "Designed for both sides",
+ "valuesTitle": "A calmer way to begin",
+ "valuesLead": "Learners get clarity before committing; teachers get a professional space built around genuine teaching work.",
+ "studentValueTitle": "For learners",
+ "studentValueBody": "Browse openly, keep bookings and assignments organised, and practise Chinese with context.",
+ "startLearning": "Create a learner account",
+ "teacherValueTitle": "For teachers",
+ "teacherValueBody": "Present your expertise, publish courses after review and manage requests in one workspace.",
+ "finalEyebrow": "Your next conversation",
+ "finalTitle": "Start by finding the person you want to learn with.",
+ "finalBody": "Explore public profiles now. Creating an account takes only a few minutes when you are ready to request a lesson."
+ },
+ "teachers": {
+ "metaTitle": "Verified Chinese teachers",
+ "metaDescription": "Search verified Chinese teachers by speciality and rating, with transparent profiles and reference rates.",
+ "eyebrow": "Teacher directory",
+ "title": "Find a teacher you can trust",
+ "lead": "Browse enabled, verified profiles and choose by expertise, language and teaching approach.",
+ "availableCount": "Verified profiles",
+ "searchLabel": "Search teachers",
+ "searchPlaceholder": "Name, language or teaching focus",
+ "specialtyLabel": "Speciality",
+ "specialtyPlaceholder": "e.g. conversation or HSK",
+ "ratingLabel": "Minimum rating",
+ "anyRating": "Any rating",
+ "ratingFour": "4.0 and above",
+ "ratingFourFive": "4.5 and above",
+ "ratingFourEight": "4.8 and above",
+ "emptyTitle": "No teachers match these filters",
+ "emptyBody": "Try a broader search or clear the filters. New verified teachers will appear as they complete review.",
+ "results": "{count} teachers",
+ "verifiedOnly": "Verified profiles only"
+ },
+ "courses": {
+ "metaTitle": "Published Chinese courses",
+ "metaDescription": "Browse published Chinese courses by topic and level before creating an account.",
+ "eyebrow": "Course directory",
+ "title": "Choose a course for the Chinese you need",
+ "lead": "Explore published courses from verified teachers, with clear focus, level and reference pricing.",
+ "publishedCount": "Published courses",
+ "searchLabel": "Search courses",
+ "searchPlaceholder": "Course title, skill or topic",
+ "categoryLabel": "Category",
+ "categoryPlaceholder": "e.g. conversation or HSK",
+ "emptyTitle": "No courses match these filters",
+ "emptyBody": "Try a broader search or clear the filters. Newly approved courses will appear here.",
+ "results": "{count} courses",
+ "publishedOnly": "Published courses only"
+ },
+ "teacherDetail": {
+ "metaTitle": "Teacher profile",
+ "metaDescription": "Review this verified Chinese teacher's experience, teaching approach, courses and reference rate.",
+ "loading": "Loading teacher profile…",
+ "notFoundTitle": "Teacher profile not found",
+ "notFoundBody": "This profile may no longer be public, or the address may be incorrect.",
+ "backToTeachers": "Back to teachers",
+ "signInToBook": "Sign in to request a lesson",
+ "requestBooking": "Request a lesson",
+ "returnToWorkspace": "Return to your workspace",
+ "confirmationRequired": "The teacher must confirm every booking request.",
+ "noCharge": "No payment is taken during the public Beta.",
+ "timezone": "Times are shown in your device time zone: {timezone}.",
+ "aboutEyebrow": "Teacher profile",
+ "aboutTitle": "About this teacher",
+ "location": "Location",
+ "methodEyebrow": "Teaching approach",
+ "methodTitle": "How lessons are taught",
+ "methodPending": "This teacher is still preparing a detailed teaching statement.",
+ "coursesEyebrow": "Published learning",
+ "coursesTitle": "Courses from this teacher",
+ "publishedCourses": "{count} published courses",
+ "noCoursesTitle": "No published courses yet",
+ "noCoursesBody": "You can still review the teacher's profile. Their courses will appear after platform review."
+ },
+ "courseDetail": {
+ "metaTitle": "Course details",
+ "metaDescription": "Review this published Chinese course's focus, level, teacher, duration and reference price.",
+ "loading": "Loading course details…",
+ "notFoundTitle": "Course not found",
+ "notFoundBody": "This course may no longer be published, or the address may be incorrect.",
+ "backToCourses": "Back to courses",
+ "signInToContinue": "Sign in to continue",
+ "openLearningSpace": "Open learning space",
+ "returnToWorkspace": "Return to your workspace",
+ "suggestedCapacity": "Suggested group size",
+ "learners": "{count} learners",
+ "meetTeacher": "View teacher profile",
+ "noPurchaseNotice": "This public Beta does not process course purchases. Prices are shown for reference only.",
+ "overviewEyebrow": "Course overview",
+ "overviewTitle": "What you will work on",
+ "descriptionPending": "The teacher is still preparing the full course description.",
+ "teacherEyebrow": "Your teacher",
+ "verifiedTeacherNote": "This course is published by a verified, enabled teacher.",
+ "viewTeacherProfile": "View full teacher profile",
+ "nextEyebrow": "Ready to continue?",
+ "nextTitle": "Choose the teacher before the timetable",
+ "nextBody": "Review the teacher's profile and request a lesson when you are signed in."
+ },
+ "recovery": {
+ "metaTitle": "Account support",
+ "metaDescription": "Contact real account support for sign-in and recovery help during the public Beta.",
+ "emailSubject": "International Chinese account support",
+ "eyebrow": "Account support",
+ "title": "Get back to your learning safely",
+ "lead": "Automated password reset is not available in this Beta. Our support contact can help verify your request.",
+ "stepOneTitle": "Use your registered email",
+ "stepOneBody": "Contact us from the email address connected to your account whenever possible.",
+ "stepTwoTitle": "Describe the issue",
+ "stepTwoBody": "Tell us whether you cannot sign in, changed email access, or noticed unusual activity. Never send your password.",
+ "stepThreeTitle": "Wait for verification",
+ "stepThreeBody": "Support may ask for non-sensitive account details before making any change.",
+ "securityTitle": "Protect your credentials",
+ "securityBody": "We will never ask for your password, verification code or Gmail app password by email.",
+ "emailSupport": "Email account support",
+ "backToSignIn": "Back to sign in",
+ "supportAddress": "Support address:"
+ },
+ "system": {
+ "403": {
+ "eyebrow": "Access restricted",
+ "title": "This area belongs to another role",
+ "body": "Your account is signed in, but it does not have permission to open this page. Return to your workspace or go back."
+ },
+ "404": {
+ "eyebrow": "Page not found",
+ "title": "We could not find that page",
+ "body": "The address may be outdated or incomplete. Explore the public teacher and course directories instead."
+ },
+ "backHome": "Back to homepage",
+ "goBack": "Go back"
+ },
+ "legal": {
+ "eyebrow": "Public Beta information",
+ "versionLabel": "Version",
+ "version": "Beta 1.0",
+ "updatedLabel": "Last updated",
+ "updatedDate": "9 August 2026",
+ "onThisPage": "On this page",
+ "betaNoticeTitle": "Please review before public promotion",
+ "betaNoticeBody": "This Beta text describes the platform's current behaviour and is provided for product transparency. It is not a substitute for professional legal review.",
+ "contactTitle": "Questions or account requests",
+ "contactBody": "Use the support page to reach the current platform contact. Do not include passwords or verification codes.",
+ "contactAction": "Contact support",
+ "terms": {
+ "title": "Terms of Service",
+ "lead": "These terms explain the practical rules for using the International Chinese public Beta.",
+ "sections": {
+ "service": {
+ "title": "The Beta service",
+ "body": "The platform supports account registration, public teacher and course discovery, booking requests, classrooms, assignments, notifications and Chinese practice. Beta features may change, pause or be withdrawn as the service develops."
+ },
+ "accounts": {
+ "title": "Accounts and roles",
+ "body": "You must provide accurate information, protect your credentials and use only the role assigned to you. Teacher profiles and courses remain non-public until the relevant platform review is complete."
+ },
+ "publicContent": {
+ "title": "Public profiles and courses",
+ "body": "Only enabled, verified teachers and published courses are intended to appear publicly. Reference prices, suggested capacity and descriptions are informational and do not create a purchase or guaranteed place."
+ },
+ "bookings": {
+ "title": "Booking requests",
+ "body": "A learner's request is not confirmed until the teacher accepts it. The current Beta does not take payment. Times use the learner's device time zone and should be checked by both parties."
+ },
+ "conduct": {
+ "title": "Respectful use",
+ "body": "Do not misuse another person's account, disrupt classes, upload unlawful material, harass users or attempt to bypass access controls. We may restrict an account to protect users and the service."
+ },
+ "availability": {
+ "title": "Availability and limitations",
+ "body": "The Beta is provided on an evolving basis without guaranteed uninterrupted availability. Real-time audio and video may not connect on strict networks because TURN relay service is not yet included."
+ }
+ }
+ },
+ "privacy": {
+ "title": "Privacy Notice",
+ "lead": "This notice summarises what the public Beta currently stores and why.",
+ "sections": {
+ "data": {
+ "title": "Information we collect",
+ "body": "We store registration details, role and profile information, session records, learning activity, bookings, classes, assignments, messages, notifications and files you intentionally provide."
+ },
+ "use": {
+ "title": "How information is used",
+ "body": "Information is used to operate accounts, verify teachers, publish approved content, coordinate learning, protect the service and provide account support. The Beta does not use a paid external AI service."
+ },
+ "sessions": {
+ "title": "Sessions and security",
+ "body": "Authentication uses database-backed sessions and secure HttpOnly cookies in production. Verification codes expire, and sensitive service credentials are not stored in browser code."
+ },
+ "files": {
+ "title": "Files and object storage",
+ "body": "Private uploads are stored in access-controlled object storage. Downloads require an authorisation check before a short-lived signed link is issued. File type, size and quota rules apply."
+ },
+ "retention": {
+ "title": "Retention and Beta operations",
+ "body": "Data is retained while needed to operate the Beta, maintain security and meet reasonable support obligations. Backups may remain for their scheduled retention period after an operational change."
+ },
+ "rights": {
+ "title": "Questions and requests",
+ "body": "To ask about your data or request an account change, use the published support contact. We may need to verify that the request belongs to the account holder before acting."
+ }
+ }
+ }
+ },
+ "booking": {
+ "noChargeNotice": "No payment is taken during the public Beta.",
+ "submitting": "Sending…",
+ "dialogTitle": "Request a lesson",
+ "successBody": "Your request has been sent to {teacher}. It is not confirmed until the teacher accepts it.",
+ "courseLabel": "Course",
+ "durationOption": "{minutes} minutes",
+ "intro": "Send {teacher} a lesson request. You can review the details before submitting.",
+ "defaultTopic": "Practical Chinese lesson",
+ "durationLegend": "Lesson duration",
+ "cancel": "Cancel",
+ "messageLabel": "Message to the teacher (optional)",
+ "pendingStatus": "Waiting for teacher confirmation",
+ "messagePlaceholder": "Share your goals, questions or anything the teacher should know.",
+ "topicPlaceholder": "For example: workplace introductions or HSK 4 speaking",
+ "errors": {
+ "startInvalid": "Choose a valid start time.",
+ "startTooLate": "That date is too far ahead. Choose an earlier time.",
+ "topicRequired": "Tell the teacher what you would like to work on.",
+ "conflict": "That time is no longer available. Choose another time and try again.",
+ "startPast": "Choose a time in the future.",
+ "submitFailed": "Your request could not be sent. Check your connection and try again."
+ },
+ "startLabel": "Preferred start time",
+ "successTime": "Requested time: {time}",
+ "successTitle": "Lesson request sent",
+ "submit": "Send request",
+ "confirmationNotice": "The teacher must confirm this request before it becomes a scheduled lesson.",
+ "timezoneHelp": "Times are shown in your device time zone: {timezone}.",
+ "customCourseOption": "A custom lesson with this teacher",
+ "topicLabel": "Learning topic",
+ "goToStudentHome": "Go to student home",
+ "stayHere": "Stay on this page",
+ "statusLabel": "Request status"
+ }
+ },
+ "account": {
+ "password": {
+ "mustResetDescription": "For your security, create a new password before continuing to your workspace.",
+ "description": "Choose a strong password that you do not use for another service.",
+ "new": "New password",
+ "mustResetTitle": "Change your temporary password",
+ "confirm": "Confirm new password",
+ "eyebrow": "Account security",
+ "rules": {
+ "number": "At least one number",
+ "letter": "Uppercase and lowercase letters",
+ "length": "At least 8 characters"
+ },
+ "errors": {
+ "strength": "Your new password does not meet all security requirements.",
+ "failed": "The password could not be changed. Check your current password and try again.",
+ "mismatch": "The new passwords do not match."
+ },
+ "success": "Password updated successfully.",
+ "submit": "Update password",
+ "title": "Change password",
+ "current": "Current password"
+ }
+ },
+ "studentHome": {
+ "learner": "Learner",
+ "common": {
+ "notAvailable": "Not available",
+ "priceOnRequest": "Ask the teacher",
+ "teacherFallback": "Chinese teacher"
+ },
+ "header": {
+ "title": "Good to see you, {name}.",
+ "lead": "Your workspace brings the next useful step forward, without turning learning into a wall of numbers.",
+ "timezone": "Times use your device time zone: {timezone}"
+ },
+ "focus": {
+ "seal": "NOW",
+ "lessonEyebrow": "Today's focus · Live lesson",
+ "lessonTitle": "Your next conversation: {topic}",
+ "lessonDescription": "Meet {teacher} at {time}. Your confirmed classroom is ready.",
+ "assignmentEyebrow": "Today's focus · Assignment",
+ "assignmentTitle": "Continue “{title}”",
+ "assignmentDescription": "From {course} · due {due}. Your saved progress stays with the assignment.",
+ "requestEyebrow": "Today's focus · Booking",
+ "requestTitle": "Your lesson request is with the teacher",
+ "requestDescription": "{teacher} is reviewing your request for {time}. You can check the teacher profile while you wait.",
+ "discoverEyebrow": "Today's focus · Discover",
+ "discoverTitle": "Find the teacher who fits your Chinese",
+ "discoverDescription": "Browse verified profiles and choose by teaching focus, language and reference rate."
+ },
+ "actions": {
+ "enterClassroom": "Enter classroom",
+ "continueAssignment": "Continue assignment",
+ "viewTeacher": "View teacher",
+ "findTeacher": "Find a teacher",
+ "retry": "Try again",
+ "confirmCancel": "Cancel booking",
+ "keepBooking": "Keep booking",
+ "cancelling": "Cancelling…",
+ "cancelBooking": "Cancel",
+ "openAssignments": "Open assignments",
+ "viewAllTeachers": "View all teachers",
+ "viewAllCourses": "View all courses",
+ "viewCourse": "View course"
+ },
+ "appointments": {
+ "eyebrow": "Next confirmed lesson",
+ "title": "Your next live class",
+ "minutes": "{count} min",
+ "defaultTopic": "Chinese conversation practice",
+ "cancelTitle": "Cancel this booking?",
+ "cancelConfirm": "Cancel “{topic}”? The teacher will be notified.",
+ "emptyMark": "OPEN",
+ "emptyTitle": "No confirmed lesson yet",
+ "emptyBody": "Choose a verified teacher when you are ready to schedule your next conversation.",
+ "pendingTitle": "Waiting for teacher confirmation",
+ "status": {
+ "pending": "Awaiting confirmation",
+ "accepted": "Confirmed",
+ "rejected": "Not accepted",
+ "cancelled": "Cancelled",
+ "completed": "Completed",
+ "unknown": "Status unavailable"
+ }
+ },
+ "assignments": {
+ "eyebrow": "Learning work",
+ "title": "Assignments to finish",
+ "noDeadline": "No deadline",
+ "defaultTitle": "Learning assignment",
+ "courseFallback": "Published course",
+ "due": "Due {date}",
+ "draft": "Saved draft",
+ "notStarted": "Not started",
+ "draftMark": "D",
+ "taskMark": "A",
+ "emptyMark": "CLEAR",
+ "emptyTitle": "You are clear for now",
+ "emptyBody": "Published assignments that still need your work will appear here."
+ },
+ "teachers": {
+ "eyebrow": "Verified guidance",
+ "title": "Teachers worth meeting",
+ "lead": "These are real, enabled teacher profiles that have passed platform review.",
+ "avatarAlt": "Portrait of {name}",
+ "seal": "T",
+ "verified": "Verified",
+ "profilePending": "Professional profile in progress",
+ "experience": "Experience",
+ "years": "{count} years",
+ "referenceRate": "Reference rate",
+ "emptyTitle": "Verified teachers are preparing",
+ "emptyBody": "No sample profiles are shown as real teachers. Approved profiles will appear here."
+ },
+ "courses": {
+ "eyebrow": "Published learning",
+ "title": "Courses to explore",
+ "lead": "Review the focus, teacher, lesson length and reference price before choosing.",
+ "coverAlt": "Cover for {title}",
+ "coverMark": "CHINESE · IN CONTEXT",
+ "defaultTitle": "Chinese course",
+ "categoryFallback": "General Chinese",
+ "summaryPending": "The teacher is preparing the course introduction.",
+ "minutes": "{count} min",
+ "emptyMark": "C",
+ "emptyTitle": "Published courses are coming",
+ "emptyBody": "Courses will appear after a verified teacher publishes them and platform review is complete."
+ },
+ "messages": {
+ "cancelled": "The booking has been cancelled."
+ },
+ "errors": {
+ "schedule": "Your schedule could not be loaded. Other learning tools are still available.",
+ "assignments": "Assignments could not be loaded. Try this section again.",
+ "teachers": "Teacher recommendations could not be loaded. Course discovery still works.",
+ "courses": "Course recommendations could not be loaded. Teacher discovery still works.",
+ "cancel": "The booking could not be cancelled. Please try again."
+ }
+ }
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 4b826f6..f780979 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -8,5 +8,707 @@
"cancel": "取消"
},
"login": "登录",
- "register": "注册"
+ "register": "注册",
+ "common": {
+ "skipToContent": "跳到主要内容",
+ "requestFailed": "请求未能完成,请稍后重试。"
+ },
+ "a11y": {
+ "skipToContent": "跳到主要内容"
+ },
+ "system": {
+ "errorBoundary": {
+ "eyebrow": "页面出现问题",
+ "title": "暂时无法显示此页面",
+ "description": "你的数据不会丢失。请重新加载页面;如果问题仍然存在,可以稍后再试。",
+ "reload": "重新加载"
+ }
+ },
+ "shell": {
+ "brandHome": "返回工作台首页",
+ "menu": {
+ "open": "打开导航菜单",
+ "close": "关闭导航菜单",
+ "drawer": "工作台导航"
+ },
+ "account": "账户",
+ "notifications": "通知",
+ "profile": "个人资料",
+ "logout": "退出登录",
+ "loggingOut": "正在退出…"
+ },
+ "roles": {
+ "student": {
+ "label": "学生"
+ },
+ "teacher": {
+ "label": "教师"
+ },
+ "administrator": {
+ "label": "管理员"
+ }
+ },
+ "nav": {
+ "student": {
+ "home": "首页",
+ "teachers": "找教师",
+ "courses": "选课程",
+ "conversation": "中文练习"
+ },
+ "teacher": {
+ "home": "首页",
+ "requests": "教学对接",
+ "courses": "课程管理"
+ },
+ "administrator": {
+ "operations": "平台运营",
+ "reviews": "审核中心",
+ "data": "数据中心"
+ }
+ },
+ "teacher": {
+ "account": {
+ "profile": "个人资料",
+ "security": "安全设置",
+ "notices": "消息通知",
+ "navigationLabel": "教师账户设置"
+ }
+ },
+ "auth": {
+ "brandShort": "国际中文教育",
+ "brandEnglish": "International Chinese",
+ "brandHomeLabel": "返回公开首页",
+ "modeNavigation": "登录与注册",
+ "legalNavigation": "法律信息",
+ "eyebrow": "走出课本,走进真实生活",
+ "heroTitle": "让中文真正走进你的生活。",
+ "heroDescription": "认识经过认证的教师,选择适合目标的学习路径,在真正重要的场景里使用中文。",
+ "quoteCaption": "合适的教师,是一段好学习旅程的起点。",
+ "loginEyebrow": "欢迎回来",
+ "loginTitle": "继续你的中文学习",
+ "loginDescription": "登录后回到课程、预约与中文练习。",
+ "roleLegend": "选择登录身份",
+ "recoveryLink": "登录遇到问题?",
+ "noAccount": "第一次使用平台?",
+ "haveAccount": "已经有账户?",
+ "betaLabel": "公开测试版",
+ "terms": "服务条款",
+ "privacy": "隐私说明",
+ "trust": {
+ "verified": "教师资料经过平台认证",
+ "firstParty": "安全的第一方会话",
+ "realLearning": "围绕真实场景学习"
+ },
+ "roles": {
+ "student": "学生",
+ "teacher": "教师",
+ "administrator": "管理员"
+ },
+ "fields": {
+ "email": "邮箱",
+ "password": "密码",
+ "verificationCode": "验证码",
+ "confirmPassword": "确认密码",
+ "name": "姓名",
+ "nationality": "国籍",
+ "region": "国家或地区",
+ "age": "年龄"
+ },
+ "placeholders": {
+ "email": "you{'@'}example.com",
+ "password": "请输入密码",
+ "fullEmail": "请输入完整邮箱地址",
+ "verificationCode": "请输入 6 位验证码",
+ "newPassword": "设置一个强密码",
+ "confirmPassword": "请再次输入密码",
+ "name": "我们应该如何称呼你?",
+ "nationality": "例如:加拿大",
+ "region": "你目前居住在哪里?",
+ "age": "请输入年龄"
+ },
+ "actions": {
+ "login": "登录",
+ "createAccount": "创建账户",
+ "sendCode": "发送验证码",
+ "sendingCode": "正在发送…",
+ "next": "继续",
+ "back": "返回"
+ },
+ "messages": {
+ "loginSuccess": "欢迎回来。",
+ "registerSuccess": "账户创建成功。",
+ "codeSent": "验证码已发送,请查看邮箱。",
+ "codeSentDevelopment": "开发环境验证码已生成。",
+ "roleRedirected": "该操作仅面向学生账户,已为你返回对应工作台。"
+ },
+ "errors": {
+ "emailRequired": "请输入邮箱。",
+ "emailInvalid": "请输入有效的邮箱地址。",
+ "passwordRequired": "请输入密码。",
+ "passwordStrength": "密码至少 8 位,并包含大小写字母和数字。",
+ "passwordConfirmationRequired": "请再次输入密码。",
+ "passwordMismatch": "两次输入的密码不一致。",
+ "invalidProfile": "暂时无法读取账户资料。",
+ "loginFailed": "登录失败,请检查信息后重试。",
+ "registerFailed": "账户创建失败,请稍后重试。",
+ "roleRequired": "请选择学生或教师身份。",
+ "codeInvalid": "请输入完整验证码。",
+ "codeSendFailed": "验证码发送失败,可以立即重试。",
+ "termsRequired": "请先同意服务条款和隐私说明。",
+ "nameRequired": "请输入姓名。",
+ "nationalityRequired": "请输入国籍。",
+ "regionRequired": "请输入国家或地区。",
+ "ageInvalid": "请输入 6 至 120 之间的年龄。",
+ "levelRequired": "请选择当前中文水平。",
+ "teacherReviewRequired": "请确认你已了解教师审核流程。"
+ },
+ "register": {
+ "eyebrow": "创建账户",
+ "title": "从一份适合你的学习档案开始",
+ "description": "三个简短步骤即可建立工作台,过程中可以随时返回修改。",
+ "progressLabel": "注册进度",
+ "accountTitle": "保护你的账户",
+ "accountDescription": "选择身份,并验证今后用于登录的邮箱。",
+ "profileTitle": "介绍一下你自己",
+ "profileDescription": "这些信息用于个性化体验、教师匹配和账户支持。",
+ "studentStepTitle": "规划你的学习路径",
+ "studentStepDescription": "选择当前水平,并告诉教师你希望实现的目标。",
+ "teacherStepTitle": "准备教师申请",
+ "teacherStepDescription": "声明相关资质。管理员审核通过后,资料才会公开。",
+ "passwordHint": "至少 8 位,并包含大小写字母和数字。",
+ "agreePrefix": "我已阅读并同意",
+ "and": "和",
+ "codeCountdown": "{seconds} 秒后可重新发送",
+ "codeSentTo": "验证码已发送至 {email}",
+ "codeValidity": "验证码 10 分钟内有效。",
+ "privacyNoteTitle": "为什么需要这些信息",
+ "privacyNote": "这些资料用于账户安全、教师匹配与平台运营,具体处理方式见隐私说明。",
+ "levelLegend": "当前中文水平",
+ "learningGoalLabel": "你希望用中文完成什么?",
+ "learningGoalPlaceholder": "例如:在工作中自信交流、准备 HSK 4,或与家人沟通。",
+ "certificateLegend": "相关资质",
+ "certificateHelp": "请选择你已取得的资质。这里仅作声明,不是证书上传,也不代表已经通过认证。",
+ "teacherReviewTitle": "教师资料需要审核",
+ "teacherReviewDescription": "管理员审核前,你的资料不会公开。平台可能联系你补充证明材料。",
+ "teacherReviewAcknowledgement": "我明白,声明资质不等于完成教师认证。",
+ "steps": {
+ "account": "账户",
+ "profile": "资料",
+ "learning": "学习"
+ },
+ "roleDescriptions": {
+ "student": "寻找教师、预约课程并管理学习进度。",
+ "teacher": "申请成为教师,认证后管理课程与教学。"
+ },
+ "levels": {
+ "beginner": "我还没有学过中文。",
+ "hsk1": "我能理解少量常用词和简单短语。",
+ "hsk2": "我能完成基础日常交流。",
+ "hsk3": "我能应对许多熟悉的生活场景。",
+ "hsk4": "我能讨论较广泛的生活和学习话题。",
+ "hsk5": "我的听读和表达较为流畅。",
+ "hsk6": "我能理解并自信表达复杂观点。"
+ },
+ "certificates": {
+ "international": "国际中文教师证书",
+ "tcsl": "对外汉语教师资格证",
+ "ability": "汉语作为外语教学能力证书",
+ "mandarin": "普通话水平测试证书"
+ }
+ },
+ "session": {
+ "offline": "暂时无法连接服务,请检查网络后重新登录。",
+ "expired": "登录会话已过期,请重新登录后继续刚才的操作。",
+ "authenticationRequired": "请先登录,完成后会返回你刚才访问的页面。"
+ },
+ "quote": "海内存知己,天涯若比邻。"
+ },
+ "public": {
+ "a11y": {
+ "skipToContent": "跳到主要内容"
+ },
+ "brand": {
+ "name": "国际中文教育",
+ "tagline": "学以致用,走进生活"
+ },
+ "language": {
+ "label": "选择语言"
+ },
+ "nav": {
+ "home": "首页",
+ "teachers": "找教师",
+ "courses": "选课程",
+ "dashboard": "我的工作台",
+ "signIn": "登录",
+ "createAccount": "创建账户",
+ "primary": "主导航",
+ "mobile": "移动端导航",
+ "toggleMenu": "展开或收起导航菜单"
+ },
+ "footer": {
+ "mission": "连接中文学习者、认证教师与真实学习场景的公开测试平台。",
+ "explore": "探索",
+ "legal": "法律信息",
+ "support": "账户支持",
+ "terms": "服务条款",
+ "privacy": "隐私说明",
+ "beta": "公开测试版 · 功能可能调整"
+ },
+ "common": {
+ "applyFilters": "应用筛选",
+ "clearFilters": "清除筛选",
+ "teacherLoadErrorTitle": "暂时无法加载教师",
+ "courseLoadErrorTitle": "暂时无法加载课程",
+ "loadErrorBody": "请检查网络后重试,平台其他功能可能仍可使用。",
+ "loadingTeachers": "正在加载认证教师…",
+ "loadingCourses": "正在加载已发布课程…",
+ "retry": "重试",
+ "pagination": "分页",
+ "previous": "上一页",
+ "next": "下一页",
+ "pageOf": "第 {page} 页,共 {total} 页",
+ "breadcrumb": "面包屑导航"
+ },
+ "teacher": {
+ "unnamed": "教师资料",
+ "verified": "认证教师",
+ "profilePending": "专业资料正在完善",
+ "bioPending": "教师正在准备个人介绍。",
+ "experience": "教学经验",
+ "years": "{count} 年",
+ "courses": "课程",
+ "languages": "授课语言",
+ "referenceRate": "参考价格",
+ "priceOnRequest": "请咨询教师",
+ "viewProfile": "查看资料",
+ "viewNamed": "查看 {name} 的资料"
+ },
+ "course": {
+ "untitled": "未命名课程",
+ "summaryPending": "课程简介正在完善。",
+ "teacher": "授课教师",
+ "duration": "课时长度",
+ "minutes": "{count} 分钟",
+ "referencePrice": "参考价格",
+ "free": "暂未标价",
+ "viewDetails": "查看课程",
+ "levels": {
+ "beginner": "入门",
+ "elementary": "初级",
+ "intermediate": "中级",
+ "advanced": "高级",
+ "all": "不限水平"
+ }
+ },
+ "home": {
+ "metaTitle": "把中文学到真实场景里",
+ "metaDescription": "浏览认证中文教师与已发布课程,从公开了解一路顺畅进入注册和预约。",
+ "eyebrow": "国际中文教育 · 公开测试版",
+ "title": "和可信赖的老师一起,把中文学到真实场景里。",
+ "lead": "注册前即可查看认证教师与实用课程;决定继续时,可以直接发起预约请求。",
+ "findTeacher": "寻找教师",
+ "exploreCourses": "浏览课程",
+ "trustNote": "先浏览,再决定。只有预约或进入学习空间时才需要注册。",
+ "stepsEyebrow": "清晰的学习路径",
+ "stepsTitle": "从好奇到开口,只需三步",
+ "stepsLead": "用真实信息做选择,与教师确认课堂,并把后续学习集中管理。",
+ "stepOneTitle": "发现",
+ "stepOneBody": "比较认证资料、授课语言、教学专长与已发布课程。",
+ "stepTwoTitle": "预约",
+ "stepTwoBody": "按你的时区提交预约,由教师确认后才会正式排课。",
+ "stepThreeTitle": "学习",
+ "stepThreeBody": "进入课堂、完成作业,并在课间持续练习中文。",
+ "teachersEyebrow": "认识教师",
+ "teachersTitle": "找到值得信赖的教师",
+ "teachersLead": "所有公开资料都来自已启用并通过平台审核的教师。",
+ "viewAllTeachers": "查看全部教师",
+ "noTeachersTitle": "首批教师正在准备",
+ "noTeachersBody": "生产环境不会用演示资料充数;教师通过审核后,真实资料会显示在这里。",
+ "joinAsTeacher": "申请成为教师",
+ "coursesEyebrow": "选择学习方式",
+ "coursesTitle": "探索已发布课程",
+ "coursesLead": "先了解课程重点、水平、建议时长和参考价格,再判断是否适合。",
+ "viewAllCourses": "查看全部课程",
+ "noCoursesTitle": "课程正在准备",
+ "noCoursesBody": "认证教师正在完善首批公开课程。平台不会把演示内容伪装成真实课程。",
+ "valuesEyebrow": "兼顾学习与教学",
+ "valuesTitle": "从容地开始一段学习",
+ "valuesLead": "学生在行动前看清信息,教师则拥有围绕真实教学工作的专业空间。",
+ "studentValueTitle": "面向学习者",
+ "studentValueBody": "开放浏览,集中管理预约与作业,在具体语境中练习中文。",
+ "startLearning": "创建学生账户",
+ "teacherValueTitle": "面向教师",
+ "teacherValueBody": "展示专业能力,审核后发布课程,并在同一工作台处理教学请求。",
+ "finalEyebrow": "下一次中文交流",
+ "finalTitle": "先找到那个你愿意跟随学习的人。",
+ "finalBody": "现在就浏览公开资料;准备预约时,几分钟即可完成账户创建。"
+ },
+ "teachers": {
+ "metaTitle": "认证中文教师",
+ "metaDescription": "按专长与评分寻找认证中文教师,查看透明资料和参考价格。",
+ "eyebrow": "教师目录",
+ "title": "寻找值得信赖的教师",
+ "lead": "浏览已启用、已认证的教师资料,按专长、语言与教学方式选择。",
+ "availableCount": "认证教师",
+ "searchLabel": "搜索教师",
+ "searchPlaceholder": "姓名、语言或教学方向",
+ "specialtyLabel": "教学专长",
+ "specialtyPlaceholder": "例如:口语或 HSK",
+ "ratingLabel": "最低评分",
+ "anyRating": "不限评分",
+ "ratingFour": "4.0 分及以上",
+ "ratingFourFive": "4.5 分及以上",
+ "ratingFourEight": "4.8 分及以上",
+ "emptyTitle": "没有符合条件的教师",
+ "emptyBody": "请扩大搜索范围或清除筛选。新教师通过认证后会出现在这里。",
+ "results": "共 {count} 位教师",
+ "verifiedOnly": "仅显示认证资料"
+ },
+ "courses": {
+ "metaTitle": "已发布中文课程",
+ "metaDescription": "注册前即可按主题与水平浏览认证教师发布的中文课程。",
+ "eyebrow": "课程目录",
+ "title": "选择真正用得上的中文课程",
+ "lead": "查看认证教师发布的课程,清楚了解重点、水平与参考价格。",
+ "publishedCount": "已发布课程",
+ "searchLabel": "搜索课程",
+ "searchPlaceholder": "课程名称、技能或主题",
+ "categoryLabel": "课程分类",
+ "categoryPlaceholder": "例如:口语或 HSK",
+ "emptyTitle": "没有符合条件的课程",
+ "emptyBody": "请扩大搜索范围或清除筛选。新课程审核通过后会显示在这里。",
+ "results": "共 {count} 门课程",
+ "publishedOnly": "仅显示已发布课程"
+ },
+ "teacherDetail": {
+ "metaTitle": "教师资料",
+ "metaDescription": "查看认证中文教师的经验、教学方式、课程与参考价格。",
+ "loading": "正在加载教师资料…",
+ "notFoundTitle": "没有找到教师资料",
+ "notFoundBody": "该资料可能已不再公开,或访问地址有误。",
+ "backToTeachers": "返回教师目录",
+ "signInToBook": "登录后预约",
+ "requestBooking": "发起预约",
+ "returnToWorkspace": "返回工作台",
+ "confirmationRequired": "所有预约都需要教师确认。",
+ "noCharge": "公开测试期间不会收取费用。",
+ "timezone": "时间按你的设备时区显示:{timezone}。",
+ "aboutEyebrow": "教师资料",
+ "aboutTitle": "关于这位教师",
+ "location": "所在地区",
+ "methodEyebrow": "教学方式",
+ "methodTitle": "课堂如何进行",
+ "methodPending": "教师正在完善详细的教学说明。",
+ "coursesEyebrow": "已发布内容",
+ "coursesTitle": "这位教师的课程",
+ "publishedCourses": "共 {count} 门已发布课程",
+ "noCoursesTitle": "暂时没有已发布课程",
+ "noCoursesBody": "你仍可先了解教师资料;课程通过平台审核后会显示在这里。"
+ },
+ "courseDetail": {
+ "metaTitle": "课程详情",
+ "metaDescription": "查看已发布中文课程的重点、水平、教师、时长与参考价格。",
+ "loading": "正在加载课程详情…",
+ "notFoundTitle": "没有找到课程",
+ "notFoundBody": "该课程可能已不再发布,或访问地址有误。",
+ "backToCourses": "返回课程目录",
+ "signInToContinue": "登录后继续",
+ "openLearningSpace": "进入学习空间",
+ "returnToWorkspace": "返回工作台",
+ "suggestedCapacity": "建议人数",
+ "learners": "{count} 人",
+ "meetTeacher": "查看教师资料",
+ "noPurchaseNotice": "公开测试版暂不处理课程购买,页面价格仅供参考。",
+ "overviewEyebrow": "课程概览",
+ "overviewTitle": "你会学习什么",
+ "descriptionPending": "教师正在完善完整课程说明。",
+ "teacherEyebrow": "授课教师",
+ "verifiedTeacherNote": "该课程由已启用的认证教师发布。",
+ "viewTeacherProfile": "查看完整教师资料",
+ "nextEyebrow": "准备继续?",
+ "nextTitle": "先选择教师,再确认时间",
+ "nextBody": "了解教师资料,登录后即可发起课堂预约。"
+ },
+ "recovery": {
+ "metaTitle": "账户支持",
+ "metaDescription": "公开测试期间,通过真实支持邮箱获取登录与账户恢复帮助。",
+ "emailSubject": "国际中文教育平台账户支持",
+ "eyebrow": "账户支持",
+ "title": "安全地回到你的学习",
+ "lead": "测试版暂未提供自动重置密码。你可以联系真实支持人员核验请求。",
+ "stepOneTitle": "使用注册邮箱",
+ "stepOneBody": "请尽量使用账户绑定的邮箱联系我们。",
+ "stepTwoTitle": "说明问题",
+ "stepTwoBody": "请说明无法登录、邮箱失效或异常活动等情况,但不要发送密码。",
+ "stepThreeTitle": "等待身份核验",
+ "stepThreeBody": "修改账户前,支持人员可能要求提供非敏感的账户信息。",
+ "securityTitle": "保护你的凭据",
+ "securityBody": "我们不会通过邮件索要密码、验证码或 Gmail 应用专用密码。",
+ "emailSupport": "发送邮件给账户支持",
+ "backToSignIn": "返回登录",
+ "supportAddress": "支持邮箱:"
+ },
+ "system": {
+ "403": {
+ "eyebrow": "访问受限",
+ "title": "这个页面属于其他角色",
+ "body": "你的账户已登录,但没有权限打开此页面。请返回自己的工作台或上一页。"
+ },
+ "404": {
+ "eyebrow": "页面不存在",
+ "title": "没有找到这个页面",
+ "body": "地址可能已失效或不完整。你可以继续浏览公开教师和课程目录。"
+ },
+ "backHome": "返回首页",
+ "goBack": "返回上一页"
+ },
+ "legal": {
+ "eyebrow": "公开测试版信息",
+ "versionLabel": "版本",
+ "version": "Beta 1.0",
+ "updatedLabel": "更新日期",
+ "updatedDate": "2026 年 8 月 9 日",
+ "onThisPage": "本页内容",
+ "betaNoticeTitle": "正式推广前请完成专业复核",
+ "betaNoticeBody": "本文案按平台当前真实行为说明测试版能力,用于产品透明度,不替代专业法律意见。",
+ "contactTitle": "问题与账户请求",
+ "contactBody": "请通过支持页面联系当前平台负责人,不要提交密码或验证码。",
+ "contactAction": "联系支持",
+ "terms": {
+ "title": "服务条款",
+ "lead": "本条款说明国际中文教育平台公开测试版的基本使用规则。",
+ "sections": {
+ "service": {
+ "title": "测试版服务",
+ "body": "平台目前支持账户注册、公开浏览教师与课程、提交预约、在线课堂、作业、通知和中文练习。随着产品完善,测试功能可能调整、暂停或下线。"
+ },
+ "accounts": {
+ "title": "账户与角色",
+ "body": "你应提供准确信息、妥善保管登录凭据,并仅使用账户对应的角色权限。教师资料和课程只有在完成相应平台审核后才会公开。"
+ },
+ "publicContent": {
+ "title": "公开资料与课程",
+ "body": "平台仅应公开已启用、已认证的教师和已发布课程。参考价格、建议人数与课程描述仅作信息展示,不构成购买、录取或席位保证。"
+ },
+ "bookings": {
+ "title": "预约请求",
+ "body": "学生提交请求后,只有教师接受才算确认。当前测试版不收取费用。时间按学生设备时区显示,双方都应在上课前再次核对。"
+ },
+ "conduct": {
+ "title": "合理使用",
+ "body": "请勿冒用账户、干扰课堂、上传违法内容、骚扰用户或绕过权限控制。为保护用户与服务,平台可以限制存在风险的账户。"
+ },
+ "availability": {
+ "title": "可用性与限制",
+ "body": "测试版持续迭代,不保证服务始终不中断。由于尚未部署 TURN 中继,严格网络环境下可能无法建立实时音视频连接。"
+ }
+ }
+ },
+ "privacy": {
+ "title": "隐私说明",
+ "lead": "本说明概括公开测试版当前保存的数据及其用途。",
+ "sections": {
+ "data": {
+ "title": "我们收集的信息",
+ "body": "平台会保存注册信息、角色与资料、会话记录、学习活动、预约、课堂、作业、消息、通知,以及你主动提供的文件。"
+ },
+ "use": {
+ "title": "信息用途",
+ "body": "这些信息用于运营账户、认证教师、发布已审核内容、协作学习、保护服务和提供账户支持。测试版目前不使用付费外部 AI 服务。"
+ },
+ "sessions": {
+ "title": "会话与安全",
+ "body": "生产环境使用数据库会话和安全的 HttpOnly Cookie 完成认证。验证码会过期,敏感服务凭据不会写入浏览器代码。"
+ },
+ "files": {
+ "title": "文件与对象存储",
+ "body": "私有文件保存在受权限控制的对象存储中。下载前会先核验权限,再签发短时有效链接;文件类型、大小和配额均有限制。"
+ },
+ "retention": {
+ "title": "保留与测试运营",
+ "body": "数据会在运营测试版、维护安全和处理合理支持请求所需期间保留。运维变更后,备份仍可能保留至既定周期结束。"
+ },
+ "rights": {
+ "title": "查询与请求",
+ "body": "如需查询数据或变更账户,请使用公开支持渠道。执行操作前,平台可能需要核验请求人确为账户持有人。"
+ }
+ }
+ }
+ },
+ "booking": {
+ "noChargeNotice": "公开测试期间不会收取费用。",
+ "submitting": "正在发送…",
+ "dialogTitle": "发起课堂预约",
+ "successBody": "预约已发送给 {teacher};教师接受前,该课堂尚未确认。",
+ "courseLabel": "关联课程",
+ "durationOption": "{minutes} 分钟",
+ "intro": "向 {teacher} 提交课堂请求,发送前可以核对全部信息。",
+ "defaultTopic": "实用中文课堂",
+ "durationLegend": "课堂时长",
+ "cancel": "取消",
+ "messageLabel": "给教师留言(选填)",
+ "pendingStatus": "等待教师确认",
+ "messagePlaceholder": "可以补充学习目标、问题或希望教师提前了解的信息。",
+ "topicPlaceholder": "例如:职场自我介绍或 HSK 4 口语",
+ "errors": {
+ "startInvalid": "请选择有效的开始时间。",
+ "startTooLate": "预约日期距离现在太远,请选择更早的时间。",
+ "topicRequired": "请告诉教师你希望学习的内容。",
+ "conflict": "该时段已不可用,请重新选择时间后再试。",
+ "startPast": "请选择未来的时间。",
+ "submitFailed": "预约发送失败,请检查网络后重试。"
+ },
+ "startLabel": "期望开始时间",
+ "successTime": "预约时间:{time}",
+ "successTitle": "预约请求已发送",
+ "submit": "发送预约",
+ "confirmationNotice": "教师接受后,预约才会成为正式课堂。",
+ "timezoneHelp": "时间按你的设备时区显示:{timezone}。",
+ "customCourseOption": "与这位教师定制一节课",
+ "topicLabel": "学习主题",
+ "goToStudentHome": "前往学生首页",
+ "stayHere": "留在当前页面",
+ "statusLabel": "预约状态"
+ }
+ },
+ "account": {
+ "password": {
+ "mustResetDescription": "为保护账户安全,请设置新密码后再进入工作台。",
+ "description": "请设置一个未在其他服务中使用的强密码。",
+ "new": "新密码",
+ "mustResetTitle": "请先修改临时密码",
+ "confirm": "确认新密码",
+ "eyebrow": "账户安全",
+ "rules": {
+ "number": "至少包含一个数字",
+ "letter": "同时包含大写和小写字母",
+ "length": "至少 8 个字符"
+ },
+ "errors": {
+ "strength": "新密码未满足全部安全要求。",
+ "failed": "密码修改失败,请检查当前密码后重试。",
+ "mismatch": "两次输入的新密码不一致。"
+ },
+ "success": "密码修改成功。",
+ "submit": "更新密码",
+ "title": "修改密码",
+ "current": "当前密码"
+ }
+ },
+ "studentHome": {
+ "learner": "学习者",
+ "common": {
+ "notAvailable": "暂无信息",
+ "priceOnRequest": "请咨询教师",
+ "teacherFallback": "中文教师"
+ },
+ "header": {
+ "title": "{name},今天继续把中文用起来。",
+ "lead": "首页只把最值得处理的下一步放到最前面,让学习不被一排数字淹没。",
+ "timezone": "时间按你的设备时区显示:{timezone}"
+ },
+ "focus": {
+ "seal": "今",
+ "lessonEyebrow": "今日重点 · 实时课堂",
+ "lessonTitle": "下一次对话:{topic}",
+ "lessonDescription": "{time} 与 {teacher} 见面,已确认的课堂可以直接进入。",
+ "assignmentEyebrow": "今日重点 · 学习作业",
+ "assignmentTitle": "继续完成《{title}》",
+ "assignmentDescription": "来自《{course}》· 截止 {due},已保存的进度会留在作业中。",
+ "requestEyebrow": "今日重点 · 预约确认",
+ "requestTitle": "预约请求正在等待教师确认",
+ "requestDescription": "{teacher} 正在查看你提交的 {time} 预约;等待期间可以继续了解教师资料。",
+ "discoverEyebrow": "今日重点 · 寻找教师",
+ "discoverTitle": "找到适合你的中文教师",
+ "discoverDescription": "浏览已认证资料,按教学方向、授课语言和参考价格做出选择。"
+ },
+ "actions": {
+ "enterClassroom": "进入课堂",
+ "continueAssignment": "继续作业",
+ "viewTeacher": "查看教师",
+ "findTeacher": "寻找教师",
+ "retry": "重试",
+ "confirmCancel": "确认取消",
+ "keepBooking": "保留预约",
+ "cancelling": "正在取消…",
+ "cancelBooking": "取消预约",
+ "openAssignments": "打开作业",
+ "viewAllTeachers": "查看全部教师",
+ "viewAllCourses": "查看全部课程",
+ "viewCourse": "查看课程"
+ },
+ "appointments": {
+ "eyebrow": "下一堂已确认课",
+ "title": "你的下一次实时课堂",
+ "minutes": "{count} 分钟",
+ "defaultTopic": "中文情景对话",
+ "cancelTitle": "取消这次预约?",
+ "cancelConfirm": "确定取消“{topic}”吗?教师会收到通知。",
+ "emptyMark": "待",
+ "emptyTitle": "还没有已确认的课堂",
+ "emptyBody": "准备好安排下一次对话时,可以从已认证教师中选择。",
+ "pendingTitle": "等待教师确认",
+ "status": {
+ "pending": "等待确认",
+ "accepted": "已确认",
+ "rejected": "未被接受",
+ "cancelled": "已取消",
+ "completed": "已完成",
+ "unknown": "状态未知"
+ }
+ },
+ "assignments": {
+ "eyebrow": "学习任务",
+ "title": "待完成作业",
+ "noDeadline": "长期有效",
+ "defaultTitle": "学习作业",
+ "courseFallback": "已发布课程",
+ "due": "截止 {date}",
+ "draft": "已保存草稿",
+ "notStarted": "尚未开始",
+ "draftMark": "稿",
+ "taskMark": "作",
+ "emptyMark": "清",
+ "emptyTitle": "当前没有待完成作业",
+ "emptyBody": "教师发布且仍需完成的作业会显示在这里。"
+ },
+ "teachers": {
+ "eyebrow": "认证指导",
+ "title": "值得认识的教师",
+ "lead": "这里展示的都是真实启用、已经通过平台审核的教师资料。",
+ "avatarAlt": "{name} 的头像",
+ "seal": "师",
+ "verified": "已认证",
+ "profilePending": "专业资料正在完善",
+ "experience": "教学经验",
+ "years": "{count} 年",
+ "referenceRate": "参考价格",
+ "emptyTitle": "认证教师正在准备",
+ "emptyBody": "平台不会用演示账户充数;真实教师通过审核后会显示在这里。"
+ },
+ "courses": {
+ "eyebrow": "已发布学习内容",
+ "title": "可以探索的课程",
+ "lead": "先了解课程重点、教师、时长和参考价格,再决定是否适合。",
+ "coverAlt": "《{title}》课程封面",
+ "coverMark": "中文 · 在场景中",
+ "defaultTitle": "中文课程",
+ "categoryFallback": "通用中文",
+ "summaryPending": "教师正在完善课程简介。",
+ "minutes": "{count} 分钟",
+ "emptyMark": "课",
+ "emptyTitle": "已发布课程正在准备",
+ "emptyBody": "认证教师发布并通过平台审核后,真实课程会显示在这里。"
+ },
+ "messages": {
+ "cancelled": "预约已取消。"
+ },
+ "errors": {
+ "schedule": "暂时无法加载学习安排,其他学习功能仍可继续使用。",
+ "assignments": "暂时无法加载作业,请单独重试这一部分。",
+ "teachers": "暂时无法加载教师推荐,课程发现仍可使用。",
+ "courses": "暂时无法加载课程推荐,教师发现仍可使用。",
+ "cancel": "取消预约失败,请稍后重试。"
+ }
+ }
}
diff --git a/src/main.js b/src/main.js
index f7fc5da..b0e4787 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,17 +1,15 @@
import { createApp } from 'vue'
import pinia from '@/stores/index'
-import '@/assets/style/tailwind.css' // 引入 Tailwind CSS
-import ElementPlus from 'element-plus'
-import 'element-plus/dist/index.css'
-import i18n from '@/i18n' // 引入 i18n
+import '@/assets/style/tailwind.css'
+import '@/assets/main.scss'
+import i18n from '@/i18n'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(pinia)
-app.use(ElementPlus)
app.use(router)
-app.use(i18n) // 使用 i18n
+app.use(i18n)
app.mount('#app')
diff --git a/src/router/index.js b/src/router/index.js
index 6a96890..a14319c 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
+
import { useStudentStore, useUserStore } from '@/stores'
const roleHome = {
@@ -8,9 +9,18 @@ const roleHome = {
}
const passwordResetRoute = {
- student: { path: '/student/center/changePassword' },
- teacher: { path: '/teacher/user', query: { tab: 'security' } },
- administrator: { path: '/administrator/center/changePasswordAdmin' }
+ student: {
+ path: '/student/center/changePassword',
+ query: { reason: 'temporary-password' }
+ },
+ teacher: {
+ path: '/teacher/user',
+ query: { tab: 'security', reason: 'temporary-password' }
+ },
+ administrator: {
+ path: '/administrator/center/changePasswordAdmin',
+ query: { reason: 'temporary-password' }
+ }
}
const router = createRouter({
@@ -18,68 +28,111 @@ const router = createRouter({
routes: [
{
path: '/',
- redirect: '/login'
+ component: () => import('@/components/public/PublicShell.vue'),
+ children: [
+ {
+ path: '',
+ name: 'publicHome',
+ component: () => import('@/views/public/PublicHome.vue')
+ },
+ {
+ path: 'teachers',
+ name: 'publicTeachers',
+ component: () => import('@/views/public/TeacherDirectory.vue')
+ },
+ {
+ path: 'teachers/:teacherId',
+ name: 'publicTeacherDetail',
+ component: () => import('@/views/public/PublicTeacherDetail.vue')
+ },
+ {
+ path: 'courses',
+ name: 'publicCourses',
+ component: () => import('@/views/public/CourseDirectory.vue')
+ },
+ {
+ path: 'courses/:courseId',
+ name: 'publicCourseDetail',
+ component: () => import('@/views/public/PublicCourseDetail.vue')
+ },
+ {
+ path: 'legal/terms',
+ name: 'terms',
+ component: () => import('@/views/public/LegalPage.vue')
+ },
+ {
+ path: 'legal/privacy',
+ name: 'privacy',
+ component: () => import('@/views/public/LegalPage.vue')
+ },
+ {
+ path: 'account-recovery',
+ name: 'accountRecovery',
+ component: () => import('@/views/public/AccountRecovery.vue')
+ },
+ {
+ path: '403',
+ name: 'forbidden',
+ component: () => import('@/views/public/SystemStatePage.vue'),
+ props: { status: '403' }
+ },
+ {
+ path: '404',
+ name: 'notFound',
+ component: () => import('@/views/public/SystemStatePage.vue'),
+ props: { status: '404' }
+ }
+ ]
},
{
path: '/login',
+ name: 'login',
component: () => import('@/views/login/loginPage.vue')
},
{
- // 学生端架子模块
path: '/student',
component: () =>
import('@/views/student/studentLayout/studentLayout.vue'),
meta: { requiresAuth: true, role: 'student' },
- redirect: '/student/home', // 重定向到首页
+ redirect: '/student/home',
children: [
- // 子路由配置
{
- // 学生首页模块
- path: 'home', // 子路由路径
+ path: 'home',
name: 'studentHome',
component: () =>
import('@/views/student/studentHomePage/studentHomePage.vue')
},
{
- // 学生预约老师模块
path: 'order',
name: 'orderTeacher',
- component: () =>
- import('@/views/student/orderTeacher/orderTeacher.vue')
+ component: () => import('@/views/public/TeacherDirectory.vue')
},
{
- // 学生网络课程模块
path: 'course',
name: 'onlineCourse',
component: () =>
import('@/views/student/onlineCourses/onlineCourses.vue')
},
{
- // 学生个人中心模块
path: 'center',
name: 'personalCenter',
- redirect: '/student/center/info', // 重定向到个人信息模块
+ redirect: '/student/center/info',
component: () =>
import('@/views/student/personalCenter/personalCenter.vue'),
-
- // 个人中心子路由
children: [
{
- // 个人信息模块
path: 'info',
name: 'personalInfo',
component: () =>
import('@/views/student/personalCenter/personalInfo.vue')
},
{
- // 修改密码模块
path: 'changePassword',
name: 'changePassword',
component: () =>
import('@/views/student/personalCenter/changePassword.vue')
},
{
- // 消息通知模块
path: 'message',
name: 'message',
component: () =>
@@ -88,23 +141,25 @@ const router = createRouter({
]
},
{
- //话轮页面
path: 'chatTurn',
name: 'chatTurn',
component: () => import('@/views/student/chatTurn/chatTurn.vue')
},
{
- //作业页面
path: 'homeWork',
name: 'homeWork',
component: () => import('@/views/student/homeWork/homeWork.vue')
},
{
- //老师详情页面
path: 'teacherDetail',
name: 'teacherDetail',
- component: () =>
- import('@/views/student/orderTeacher/teacherDetail.vue')
+ redirect: (to) => {
+ const teacherId =
+ typeof to.query.teacherId === 'string' ? to.query.teacherId : ''
+ return teacherId
+ ? { name: 'publicTeacherDetail', params: { teacherId } }
+ : { name: 'publicTeachers' }
+ }
},
{
path: 'liveClass',
@@ -123,44 +178,37 @@ const router = createRouter({
path: '/teacher',
component: () => import('@/views/teacher/LayoutPage/LayoutPage.vue'),
meta: { requiresAuth: true, role: 'teacher' },
- redirect: '/teacher/home', // 重定向到首页
+ redirect: '/teacher/home',
children: [
- // 首页
{
path: 'home',
name: 'home',
component: () =>
import('@/views/teacher/teacherHomePage/teacherHomePage.vue')
},
- // 授课对接模块
{
path: 'teachingDocking',
name: 'teachingDocking',
component: () =>
import('@/views/teacher/TeachingDockingPage/teachingDockingPage.vue')
},
- // 网络课程模块
{
path: 'onlineCourses',
name: 'onlineCourses',
component: () =>
- import('@/views/teacher/OnlineCoursesPage/OnlineCoursesPage.vue'),
- children: []
+ import('@/views/teacher/OnlineCoursesPage/OnlineCoursesPage.vue')
},
- // 个人中心模块
{
path: 'user',
name: 'user',
component: () => import('@/views/teacher/UserPage/UserPage.vue')
},
- // 上传课程模块
{
path: 'uploadCourses',
name: 'uploadCourses',
component: () =>
import('@/views/teacher/UploadCoursesPage/UploadCoursesPage.vue')
},
- // 课程详情模块
{
path: 'courseDetails',
name: 'courseDetails',
@@ -179,48 +227,40 @@ const router = createRouter({
component: () =>
import('@/views/administrator/administratorLayout/administratorLayout.vue'),
meta: { requiresAuth: true, role: 'administrator' },
- redirect: '/administrator/courseDocking', // 重定向到首页
+ redirect: '/administrator/courseDocking',
children: [
- // 子路由配置
{
- //课程对接模块
- path: 'courseDocking', // 子路由路径
+ path: 'courseDocking',
name: 'courseDocking',
component: () =>
import('@/views/administrator/courseDocking/courseDocking.vue')
},
{
- //审核中心模块
path: 'auditCenter',
name: 'auditCenter',
component: () =>
import('@/views/administrator/auditCenter/auditCenter.vue')
},
{
- //数据中心模块
path: 'dataCenter',
name: 'dataCenter',
component: () =>
import('@/views/administrator/dataCenter/dataCenter.vue')
},
{
- //个人中心模块
path: 'center',
name: 'center',
redirect: '/administrator/center/changePasswordAdmin',
component: () =>
import('@/views/administrator/personalCenter/personalCenter.vue'),
- // 个人中心子路由
children: [
{
- // 修改密码模块
path: 'changePasswordAdmin',
name: 'changePasswordAdmin',
component: () =>
import('@/views/administrator/personalCenter/changePassword.vue')
},
{
- // 消息通知模块
path: 'AdminMessage',
name: 'AdminMessage',
component: () =>
@@ -232,7 +272,10 @@ const router = createRouter({
},
{
path: '/:pathMatch(.*)*',
- redirect: '/login'
+ redirect: (to) => ({
+ path: '/404',
+ query: { from: to.fullPath }
+ })
}
]
})
@@ -240,7 +283,14 @@ const router = createRouter({
router.beforeEach(async (to) => {
const userStore = useUserStore()
const studentStore = useStudentStore()
- await userStore.restoreSession()
+ const authRecord = to.matched.find((record) => record.meta.requiresAuth)
+ const needsSynchronousSession = Boolean(authRecord) || to.path === '/login'
+
+ if (needsSynchronousSession) {
+ await userStore.restoreSession()
+ } else if (!userStore.sessionRestored) {
+ void userStore.restoreSession()
+ }
const currentRole = userStore.role
@@ -258,29 +308,32 @@ router.beforeEach(async (to) => {
if (userStore.isAuthenticated && currentRole === 'student') {
studentStore.setUserInfo(userStore.profile)
- } else {
+ } else if (!userStore.isAuthenticated) {
studentStore.clearUserInfo()
}
if (to.path === '/login' && userStore.isAuthenticated && currentRole) {
- return roleHome[currentRole] || '/login'
+ return roleHome[currentRole] || '/'
}
- const authRecord = to.matched.find((record) => record.meta.requiresAuth)
- if (!authRecord) {
- return true
- }
+ if (!authRecord) return true
if (!userStore.isAuthenticated || !currentRole) {
return {
path: '/login',
- query: { redirect: to.fullPath }
+ query: {
+ redirect: to.fullPath,
+ reason: userStore.restoreError ? 'offline' : 'authentication-required'
+ }
}
}
const expectedRole = authRecord.meta.role
if (expectedRole && expectedRole !== currentRole) {
- return roleHome[currentRole] || '/login'
+ return {
+ path: '/403',
+ query: { from: to.fullPath, role: currentRole }
+ }
}
return true
@@ -289,7 +342,7 @@ router.beforeEach(async (to) => {
let unauthorizedRedirectInProgress = false
if (typeof window !== 'undefined') {
- window.addEventListener('auth:unauthorized', async () => {
+ window.addEventListener('auth:unauthorized', async (event) => {
if (unauthorizedRedirectInProgress) return
unauthorizedRedirectInProgress = true
@@ -304,7 +357,10 @@ if (typeof window !== 'undefined') {
if (currentRoute.path !== '/login') {
await router.replace({
path: '/login',
- query: { redirect: currentRoute.fullPath }
+ query: {
+ redirect: currentRoute.fullPath,
+ reason: event.detail?.reason || 'expired'
+ }
})
}
} finally {
@@ -313,4 +369,21 @@ if (typeof window !== 'undefined') {
})
}
+let chunkRecoveryAttempted = false
+router.onError((error) => {
+ const message = String(error?.message || '')
+ const isChunkFailure =
+ message.includes('Failed to fetch dynamically imported module') ||
+ message.includes('Importing a module script failed')
+
+ if (
+ isChunkFailure &&
+ !chunkRecoveryAttempted &&
+ typeof window !== 'undefined'
+ ) {
+ chunkRecoveryAttempted = true
+ window.location.reload()
+ }
+})
+
export default router
diff --git a/src/stores/modules/localeStore.js b/src/stores/modules/localeStore.js
index 002cb1b..1a33b31 100644
--- a/src/stores/modules/localeStore.js
+++ b/src/stores/modules/localeStore.js
@@ -1,15 +1,32 @@
import { defineStore } from 'pinia'
-import i18n from '@/i18n'
+import i18n, { detectInitialLocale, normalizeLocale } from '@/i18n'
+
+function applyDocumentLanguage(locale) {
+ if (typeof document === 'undefined') return
+ document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
+}
export const useLocaleStore = defineStore('locale', {
state: () => ({
- locale: 'zh' // 默认语言
+ locale: detectInitialLocale()
}),
actions: {
- setLocale(locale) {
- this.locale = locale
- localStorage.setItem('lang', locale)
- i18n.global.locale.value = locale // 同步到 vue-i18n
+ initialize() {
+ this.setLocale(this.locale, { persist: false })
+ },
+ setLocale(locale, { persist = true } = {}) {
+ const nextLocale = normalizeLocale(locale) || 'en'
+ this.locale = nextLocale
+ i18n.global.locale.value = nextLocale
+ applyDocumentLanguage(nextLocale)
+
+ if (persist && typeof window !== 'undefined') {
+ try {
+ window.localStorage.setItem('lang', nextLocale)
+ } catch {
+ // The active locale still works when persistent storage is blocked.
+ }
+ }
}
}
})
diff --git a/src/stores/modules/user.js b/src/stores/modules/user.js
index 034dc2a..3c6d3cb 100644
--- a/src/stores/modules/user.js
+++ b/src/stores/modules/user.js
@@ -33,6 +33,7 @@ export const useUserStore = defineStore('big-store', () => {
const profile = ref(null)
const sessionRestored = ref(false)
const isRestoring = ref(false)
+ const restoreError = ref(null)
let restorePromise = null
const isAuthenticated = computed(() =>
@@ -55,6 +56,7 @@ export const useUserStore = defineStore('big-store', () => {
role.value = resolvedRole
profile.value = { ...nextProfile, role: resolvedRole }
+ restoreError.value = null
sessionRestored.value = true
return profile.value
}
@@ -70,11 +72,21 @@ export const useUserStore = defineStore('big-store', () => {
if (restorePromise) return restorePromise
isRestoring.value = true
+ restoreError.value = null
restorePromise = getSession()
.then((response) => setSession(response.data?.data))
- .catch(() => {
- clearSession()
- return null
+ .catch((error) => {
+ if (Number(error?.response?.status) === 401) {
+ clearSession()
+ return null
+ }
+
+ restoreError.value =
+ error?.response?.data?.msg ||
+ error?.message ||
+ 'Unable to restore the session'
+ sessionRestored.value = true
+ return profile.value
})
.finally(() => {
isRestoring.value = false
@@ -107,6 +119,7 @@ export const useUserStore = defineStore('big-store', () => {
profile,
sessionRestored,
isRestoring,
+ restoreError,
isAuthenticated,
setSession,
clearSession,
diff --git a/src/styles/base.scss b/src/styles/base.scss
new file mode 100644
index 0000000..0088935
--- /dev/null
+++ b/src/styles/base.scss
@@ -0,0 +1,77 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ min-height: 100%;
+ scroll-behavior: smooth;
+ text-size-adjust: 100%;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ color: var(--color-ink);
+ background: var(--color-paper);
+ font-family: var(--font-body);
+ line-height: 1.6;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+button,
+input,
+select,
+textarea {
+ color: inherit;
+ font: inherit;
+}
+
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button:not(:disabled),
+[role='button']:not([aria-disabled='true']) {
+ cursor: pointer;
+}
+
+img,
+svg {
+ display: block;
+ max-width: 100%;
+}
+
+a {
+ color: inherit;
+}
+
+:focus-visible {
+ outline: 3px solid var(--color-focus);
+ outline-offset: 3px;
+}
+
+::selection {
+ color: var(--color-surface);
+ background: var(--color-cinnabar);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/src/styles/tokens.scss b/src/styles/tokens.scss
new file mode 100644
index 0000000..8fd76aa
--- /dev/null
+++ b/src/styles/tokens.scss
@@ -0,0 +1,75 @@
+:root {
+ color-scheme: light;
+
+ --color-paper: #f5f1e8;
+ --color-surface: #fffdf7;
+ --color-ink: #102a2f;
+ --color-ink-soft: #173a40;
+ --color-jade: #2f746b;
+ --color-cinnabar: #b64738;
+ --color-gold: #b88a3b;
+ --color-muted: #62757a;
+ --color-focus: #1769d2;
+ --color-line: rgba(16, 42, 47, 0.14);
+ --color-line-strong: rgba(16, 42, 47, 0.24);
+ --color-success-soft: #e4f0ea;
+ --color-danger-soft: #f8e9e5;
+
+ /* Public discovery components are also embedded in authenticated shells. */
+ --public-paper: var(--color-paper);
+ --public-surface: var(--color-surface);
+ --public-ink: var(--color-ink);
+ --public-ink-soft: var(--color-ink-soft);
+ --public-jade: var(--color-jade);
+ --public-vermilion: var(--color-cinnabar);
+ --public-gold: var(--color-gold);
+ --public-muted: var(--color-muted);
+ --public-line: #d8d3c7;
+ --public-focus: var(--color-focus);
+ --public-radius-sm: var(--radius-sm);
+ --public-radius-md: var(--radius-md);
+ --public-radius-lg: var(--radius-lg);
+
+ --font-display: 'Songti SC', 'STSong', 'Noto Serif CJK SC', Georgia, serif;
+ --font-body:
+ 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Segoe UI', sans-serif;
+ --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
+
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+
+ --radius-sm: 10px;
+ --radius-md: 16px;
+ --radius-lg: 24px;
+ --radius-pill: 999px;
+
+ --shadow-sm:
+ 0 1px 2px rgba(16, 42, 47, 0.04), 0 7px 20px rgba(16, 42, 47, 0.06);
+ --shadow-md: 0 16px 42px rgba(16, 42, 47, 0.11);
+ --shadow-lg: 0 28px 80px rgba(16, 42, 47, 0.18);
+
+ --content-max: 1280px;
+ --header-height: 72px;
+ --transition-fast: 160ms ease;
+ --transition-base: 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
+
+ --el-color-primary: var(--color-jade);
+ --el-color-primary-light-3: #659990;
+ --el-color-primary-light-5: #8fb7af;
+ --el-color-primary-light-7: #bdd3cd;
+ --el-color-primary-light-8: #d4e3df;
+ --el-color-primary-light-9: #eaf1ef;
+ --el-color-primary-dark-2: #255d56;
+ --el-border-radius-base: var(--radius-sm);
+ --el-border-color: var(--color-line);
+ --el-text-color-primary: var(--color-ink);
+ --el-text-color-regular: var(--color-muted);
+ --el-bg-color: var(--color-surface);
+}
diff --git a/src/utils/request.js b/src/utils/request.js
index cab3658..787f94f 100644
--- a/src/utils/request.js
+++ b/src/utils/request.js
@@ -1,6 +1,6 @@
import axios from 'axios'
-import { ElMessage } from 'element-plus'
import { API_BASE_URL } from '@/config/runtime'
+import i18n from '@/i18n'
const AUTH_ENDPOINTS = new Set([
'/auth/login',
@@ -11,6 +11,22 @@ const AUTH_ENDPOINTS = new Set([
])
let unauthorizedEventPending = false
+let messageComponentPromise = null
+
+function showErrorMessage(message) {
+ if (!messageComponentPromise) {
+ messageComponentPromise = Promise.all([
+ import('element-plus/es/components/message/index'),
+ import('element-plus/theme-chalk/el-message.css')
+ ]).then(([module]) => module.ElMessage)
+ }
+
+ messageComponentPromise
+ .then((ElMessage) => ElMessage.error(message))
+ .catch(() => {
+ console.error(message)
+ })
+}
const instance = axios.create({
baseURL: API_BASE_URL,
@@ -48,6 +64,15 @@ function handleErrorResponse(error) {
responseData?.code ?? error?.response?.status ?? error?.status
const requestPath = String(error?.config?.url || '').split('?')[0]
+ if (!error?.response && typeof window !== 'undefined') {
+ error.isNetworkError = true
+ window.dispatchEvent(
+ new CustomEvent('network:unavailable', {
+ detail: { requestPath }
+ })
+ )
+ }
+
if (
Number(errorCode) === 401 &&
!AUTH_ENDPOINTS.has(requestPath) &&
@@ -55,15 +80,22 @@ function handleErrorResponse(error) {
typeof window !== 'undefined'
) {
unauthorizedEventPending = true
- window.dispatchEvent(new CustomEvent('auth:unauthorized'))
+ window.dispatchEvent(
+ new CustomEvent('auth:unauthorized', {
+ detail: { reason: 'expired', requestPath }
+ })
+ )
window.setTimeout(() => {
unauthorizedEventPending = false
}, 0)
}
- if (Number(errorCode) >= 400 && Number(errorCode) !== 401) {
- const message = responseData?.msg || responseData?.message || '服务异常'
- ElMessage.error(String(message))
+ if (
+ Number(errorCode) >= 400 &&
+ Number(errorCode) !== 401 &&
+ !AUTH_ENDPOINTS.has(requestPath)
+ ) {
+ showErrorMessage(i18n.global.t('common.requestFailed'))
error.messageShown = true
}
diff --git a/src/views/administrator/administratorLayout/administratorLayout.vue b/src/views/administrator/administratorLayout/administratorLayout.vue
index e5200c5..c877523 100644
--- a/src/views/administrator/administratorLayout/administratorLayout.vue
+++ b/src/views/administrator/administratorLayout/administratorLayout.vue
@@ -1,37 +1,82 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/views/login/loginPage.vue b/src/views/login/loginPage.vue
index 710eda0..0ece08e 100644
--- a/src/views/login/loginPage.vue
+++ b/src/views/login/loginPage.vue
@@ -1,68 +1,127 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
登录
-
-
注册
+
+
-
-
-
-
-
-
-
- 欢迎登陆
-
-
- 国际中文教育平台
-
+
+
+
+ {{ t('auth.eyebrow') }}
+ {{ t('auth.heroTitle') }}
+ {{ t('auth.heroDescription') }}
+
+ -
+ 01{{ t('auth.trust.verified') }}
+
+ -
+ 02{{ t('auth.trust.firstParty') }}
+
+ -
+ 03{{ t('auth.trust.realLearning') }}
+
+
+
+ {{ t('auth.quote') }}
+ {{ t('auth.quoteCaption') }}
+
+
+
+
+
+
+
{{ t('auth.loginEyebrow') }}
+
{{ t('auth.loginTitle') }}
+
{{ t('auth.loginDescription') }}
-
-
-
-
-
-
+
+
+ {{ t('auth.actions.login') }}
+
+
+
+
+ {{ t('auth.noAccount') }}
+
+ {{ t('auth.actions.createAccount') }}
+
+
+
+
+
+
+
+
diff --git a/src/views/public/AccountRecovery.vue b/src/views/public/AccountRecovery.vue
new file mode 100644
index 0000000..1442f2c
--- /dev/null
+++ b/src/views/public/AccountRecovery.vue
@@ -0,0 +1,295 @@
+
+
+
+
+
+
+
{{ t('public.recovery.eyebrow') }}
+
{{ t('public.recovery.title') }}
+
{{ t('public.recovery.lead') }}
+
+
+ -
+ 01
+
+
{{ t('public.recovery.stepOneTitle') }}
+
{{ t('public.recovery.stepOneBody') }}
+
+
+ -
+ 02
+
+
{{ t('public.recovery.stepTwoTitle') }}
+
{{ t('public.recovery.stepTwoBody') }}
+
+
+ -
+ 03
+
+
{{ t('public.recovery.stepThreeTitle') }}
+
{{ t('public.recovery.stepThreeBody') }}
+
+
+
+
+
+
{{ t('public.recovery.securityTitle') }}
+
{{ t('public.recovery.securityBody') }}
+
+
+
+
+ {{ t('public.recovery.emailSupport') }}
+ →
+
+
{{
+ t('public.recovery.backToSignIn')
+ }}
+
+
+ {{ t('public.recovery.supportAddress') }}
+ {{ supportEmail }}
+
+
+
+
+
+
diff --git a/src/views/public/CourseDirectory.vue b/src/views/public/CourseDirectory.vue
new file mode 100644
index 0000000..cac3b21
--- /dev/null
+++ b/src/views/public/CourseDirectory.vue
@@ -0,0 +1,474 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('public.courses.results', { count: total }) }}
+
{{ t('public.courses.publishedOnly') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/public/LegalPage.vue b/src/views/public/LegalPage.vue
new file mode 100644
index 0000000..fe124ef
--- /dev/null
+++ b/src/views/public/LegalPage.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('public.legal.betaNoticeTitle') }}
+
{{ t('public.legal.betaNoticeBody') }}
+
+
+
+ {{ String(index + 1).padStart(2, '0') }}
+
+
{{ section.title }}
+
{{ section.body }}
+
+
+
+
+
+
{{ t('public.legal.contactTitle') }}
+
{{ t('public.legal.contactBody') }}
+
+
+ {{ t('public.legal.contactAction') }}
+ →
+
+
+
+
+
+
+
+
diff --git a/src/views/public/PublicCourseDetail.vue b/src/views/public/PublicCourseDetail.vue
new file mode 100644
index 0000000..9145939
--- /dev/null
+++ b/src/views/public/PublicCourseDetail.vue
@@ -0,0 +1,626 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ course.category }}
+ {{ levelLabel }}
+
+
{{ course.title }}
+
{{ course.summary || t('public.course.summaryPending') }}
+
+
+
+
- {{ t('public.course.duration') }}
+ -
+ {{
+ t('public.course.minutes', {
+ count: Number(course.durationMinutes) || 0
+ })
+ }}
+
+
+
+
- {{ t('public.courseDetail.suggestedCapacity') }}
+ -
+ {{
+ t('public.courseDetail.learners', {
+ count: Number(course.capacity) || 0
+ })
+ }}
+
+
+
+
- {{ t('public.course.referencePrice') }}
+ - {{ referencePrice }}
+
+
+
+
+
+ {{ t('public.courseDetail.meetTeacher') }}
+ →
+
+
+ {{ learningLabel }}
+
+
+
+ {{ t('public.courseDetail.noPurchaseNotice') }}
+
+
+
+
+
+
+
+ {{ t('public.courseDetail.overviewEyebrow') }}
+
+ {{ t('public.courseDetail.overviewTitle') }}
+
+ {{
+ course.description || t('public.courseDetail.descriptionPending')
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('public.courseDetail.nextEyebrow') }}
+
+
{{ t('public.courseDetail.nextTitle') }}
+
{{ t('public.courseDetail.nextBody') }}
+
+
+ {{ t('public.courseDetail.meetTeacher') }}
+ →
+
+
+
+
+
+
+
diff --git a/src/views/public/PublicHome.vue b/src/views/public/PublicHome.vue
new file mode 100644
index 0000000..f550bcd
--- /dev/null
+++ b/src/views/public/PublicHome.vue
@@ -0,0 +1,825 @@
+
+
+
+
+
+
+
{{ t('public.home.eyebrow') }}
+
{{ t('public.home.title') }}
+
{{ t('public.home.lead') }}
+
+
+ {{ t('public.home.findTeacher') }} →
+
+
+ {{ t('public.home.exploreCourses') }}
+
+
+
+
+ {{ t('public.home.trustNote') }}
+
+
+
+
+
+
+
+
+
A
+
B
+
Beta
+
VOICE · FORM · MEANING
+
+
+
+
+
+
{{ t('public.home.stepsEyebrow') }}
+
{{ t('public.home.stepsTitle') }}
+
{{ t('public.home.stepsLead') }}
+
+
+ -
+ 01
+
+
{{ t('public.home.stepOneTitle') }}
+
{{ t('public.home.stepOneBody') }}
+
+
+ -
+ 02
+
+
{{ t('public.home.stepTwoTitle') }}
+
{{ t('public.home.stepTwoBody') }}
+
+
+ -
+ 03
+
+
{{ t('public.home.stepThreeTitle') }}
+
{{ t('public.home.stepThreeBody') }}
+
+
+
+
+
+
+
+
+
{{ t('public.home.teachersEyebrow') }}
+
{{ t('public.home.teachersTitle') }}
+
{{ t('public.home.teachersLead') }}
+
+
+ {{ t('public.home.viewAllTeachers') }}
+ ({{ teacherResource.total }})
+ →
+
+
+
+
+
+
+
+
+
+
+
{{ t('public.home.coursesEyebrow') }}
+
{{ t('public.home.coursesTitle') }}
+
{{ t('public.home.coursesLead') }}
+
+
+ {{ t('public.home.viewAllCourses') }}
+ ({{ courseResource.total }})
+ →
+
+
+
+
+
+
+
+
+
+
{{ t('public.home.valuesEyebrow') }}
+
{{ t('public.home.valuesTitle') }}
+
{{ t('public.home.valuesLead') }}
+
+
+ 01
+ {{ t('public.home.studentValueTitle') }}
+ {{ t('public.home.studentValueBody') }}
+ {{ t('public.home.startLearning') }} →
+
+
+ 02
+ {{ t('public.home.teacherValueTitle') }}
+ {{ t('public.home.teacherValueBody') }}
+
+ {{ t('public.home.joinAsTeacher') }} →
+
+
+
+
+
+ {{ t('public.home.finalEyebrow') }}
+ {{ t('public.home.finalTitle') }}
+ {{ t('public.home.finalBody') }}
+
+ {{ t('public.home.findTeacher') }} →
+
+
+
+
+
+
diff --git a/src/views/public/PublicTeacherDetail.vue b/src/views/public/PublicTeacherDetail.vue
new file mode 100644
index 0000000..811da05
--- /dev/null
+++ b/src/views/public/PublicTeacherDetail.vue
@@ -0,0 +1,1310 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
{{ initials }}
+
+
+
+
+ {{ teacher.displayName || t('public.teacher.unnamed') }}
+
+
+
+ {{ t('public.teacher.verified') }}
+
+
+
+ {{ teacher.title || t('public.teacher.profilePending') }}
+
+
+ {{ teacher.school }}
+
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('public.teacherDetail.aboutEyebrow') }}
+
+
{{ t('public.teacherDetail.aboutTitle') }}
+
+ {{ teacher.bio || t('public.teacher.bioPending') }}
+
+
+
+
+
- {{ t('public.teacher.experience') }}
+ -
+ {{
+ t('public.teacher.years', {
+ count: Number(teacher.experienceYears) || 0
+ })
+ }}
+
+
+
+
- {{ t('public.teacher.languages') }}
+ - {{ languages.length ? languages.join(' · ') : '—' }}
+
+
+
- {{ t('public.teacherDetail.location') }}
+ -
+ {{
+ [teacher.region, teacher.country]
+ .filter(Boolean)
+ .join(', ') || '—'
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('public.teacherDetail.coursesEyebrow') }}
+
+
+ {{ t('public.teacherDetail.coursesTitle') }}
+
+
+
+ {{
+ t('public.teacherDetail.publishedCourses', {
+ count: courses.length
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ bookingDialogTitle }}
+
+
+
+ ✓
+
+ {{
+ t('public.booking.successBody', {
+ teacher: teacher?.displayName
+ })
+ }}
+
+
+
+
- {{ t('public.booking.statusLabel') }}
+ - {{ t('public.booking.pendingStatus') }}
+
+
+
- {{ t('public.booking.startLabel') }}
+ -
+ {{ t('public.booking.successTime', { time: successTime }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/public/SystemStatePage.vue b/src/views/public/SystemStatePage.vue
new file mode 100644
index 0000000..add5621
--- /dev/null
+++ b/src/views/public/SystemStatePage.vue
@@ -0,0 +1,186 @@
+
+
+
+
+ {{ code }}
+
+
{{ t(`public.system.${code}.eyebrow`) }}
+
+ {{ t(`public.system.${code}.title`) }}
+
+
{{ t(`public.system.${code}.body`) }}
+
+
+ {{
+ userStore.isAuthenticated
+ ? t('public.nav.dashboard')
+ : t('public.system.backHome')
+ }}
+ →
+
+
+ {{ t('public.system.goBack') }}
+
+
+
+
+
+
+
diff --git a/src/views/public/TeacherDirectory.vue b/src/views/public/TeacherDirectory.vue
new file mode 100644
index 0000000..47e929f
--- /dev/null
+++ b/src/views/public/TeacherDirectory.vue
@@ -0,0 +1,491 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('public.teachers.results', { count: total }) }}
+
+
{{ t('public.teachers.verifiedOnly') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/student/digitalHuman/TeachDetails.vue b/src/views/student/digitalHuman/TeachDetails.vue
index 2f4b785..e1887f0 100644
--- a/src/views/student/digitalHuman/TeachDetails.vue
+++ b/src/views/student/digitalHuman/TeachDetails.vue
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import teacherAvatar from '@/assets/icon/teacher.png'
-import studentAvatar from '@/assets/student/avatar.png'
+import studentAvatar from '@/assets/student/avatar-default.svg'
import {
createDialogue,
getDialogue,
diff --git a/src/views/student/studentHomePage/studentHomePage.vue b/src/views/student/studentHomePage/studentHomePage.vue
index 4f9bd7e..8a48c0d 100644
--- a/src/views/student/studentHomePage/studentHomePage.vue
+++ b/src/views/student/studentHomePage/studentHomePage.vue
@@ -1,117 +1,356 @@
-