diff --git a/src/web/static/js/app.js b/src/web/static/js/app.js
index 82be1c6..9f14758 100644
--- a/src/web/static/js/app.js
+++ b/src/web/static/js/app.js
@@ -702,10 +702,11 @@ class UIManager {
/**
* 更新提交按钮状态
*/
- static updateSubmitButton() {
- const btn = document.getElementById('submit-btn');
- const btnIcon = btn.querySelector('.btn-icon');
- const btnText = btn.querySelector('.btn-text');
+ static updateSubmitButton() {
+ const btn = document.getElementById('submit-btn');
+ if (!btn) return;
+ const btnIcon = btn.querySelector('.btn-icon');
+ const btnText = btn.querySelector('.btn-text');
if (!getAuthStorage()) {
disableProtectedActions();
@@ -715,45 +716,65 @@ class UIManager {
return;
}
- const selectedURL = getSelectedURL();
- const token = StorageManager.get(APP_CONFIG.STORAGE_KEYS.BEARER_TOKEN);
-
- const canSubmit = selectedURL && token && !currentTask;
+ const selectedURL = getSelectedURL();
+ const token = StorageManager.get(APP_CONFIG.STORAGE_KEYS.BEARER_TOKEN);
+ const authPrompt = document.getElementById('auth-missing-prompt');
+
+ if (authPrompt) {
+ authPrompt.hidden = Boolean(token);
+ }
+
+ const canSubmit = selectedURL && token && !currentTask;
btn.disabled = !canSubmit;
if (currentTask) {
btnIcon.textContent = '⏳';
btnText.textContent = '处理中...';
- } else if (!selectedURL) {
- btnIcon.textContent = '📝';
- btnText.textContent = '请输入包含视频链接的内容';
- } else if (!token) {
- btnIcon.textContent = '🔐';
- btnText.textContent = '请在高级设置中填写访问令牌';
- } else {
- btnIcon.textContent = '🚀';
- btnText.textContent = '开始转录';
+ } else if (!selectedURL) {
+ btnIcon.textContent = '📝';
+ btnText.textContent = '开始转录';
+ } else {
+ btnIcon.textContent = '🚀';
+ btnText.textContent = '开始转录';
}
}
/**
* 切换高级设置显示状态
*/
- static toggleAdvancedSettings() {
- const settings = document.getElementById('advanced-settings');
- const icon = document.querySelector('.toggle-icon');
-
- isAdvancedSettingsExpanded = !isAdvancedSettingsExpanded;
-
- if (isAdvancedSettingsExpanded) {
- settings.classList.add('expanded');
- icon.classList.add('rotated');
- } else {
- settings.classList.remove('expanded');
- icon.classList.remove('rotated');
- }
- }
+ static toggleAdvancedSettings() {
+ const settings = document.getElementById('advanced-settings');
+ const toggle = document.getElementById('advanced-toggle');
+ const icon = toggle && toggle.querySelector('.toggle-icon');
+ if (!settings || !toggle) return;
+
+ isAdvancedSettingsExpanded = !isAdvancedSettingsExpanded;
+
+ if (isAdvancedSettingsExpanded) {
+ settings.classList.add('expanded');
+ settings.hidden = false;
+ toggle.setAttribute('aria-expanded', 'true');
+ if (icon) icon.classList.add('rotated');
+ } else {
+ settings.classList.remove('expanded');
+ settings.hidden = true;
+ toggle.setAttribute('aria-expanded', 'false');
+ if (icon) icon.classList.remove('rotated');
+ }
+ }
+
+ /** Toggle speaker recognition, a high-frequency option kept before the CTA. */
+ static toggleTranscriptionOptions() {
+ const options = document.getElementById('transcription-options');
+ const toggle = document.getElementById('transcription-options-toggle');
+ if (!options || !toggle) return;
+ const expanded = options.hidden;
+ options.hidden = !expanded;
+ toggle.setAttribute('aria-expanded', String(expanded));
+ const icon = toggle.querySelector('.toggle-icon');
+ if (icon) icon.classList.toggle('rotated', expanded);
+ }
/**
* 切换令牌可见性
@@ -796,17 +817,23 @@ class UIManager {
/**
* 处理文本输入,实时URL提取和预览
*/
-function handleTextInput(textarea) {
- const text = textarea.value;
- const urlResults = URLExtractor.extractAndRankURLs(text);
-
- const previewContainer = document.getElementById('url-preview');
-
- if (urlResults.length === 0) {
- previewContainer.innerHTML = '
未检测到URL
';
- UIManager.updateSubmitButton();
- return;
- }
+function handleTextInput(textarea) {
+ const text = textarea.value;
+ const urlResults = URLExtractor.extractAndRankURLs(text);
+
+ const previewContainer = document.getElementById('url-preview');
+ const inputFeedback = document.getElementById('input-feedback');
+ previewContainer.hidden = urlResults.length === 0;
+
+ if (urlResults.length === 0) {
+ previewContainer.innerHTML = '';
+ if (inputFeedback) {
+ inputFeedback.textContent = text.trim() ? '未检测到可识别的视频链接,请检查分享文案。' : '';
+ inputFeedback.hidden = !text.trim();
+ }
+ UIManager.updateSubmitButton();
+ return;
+ }
// 显示提取的URL,最高分的作为默认选择
let html = '
';
@@ -824,7 +851,11 @@ function handleTextInput(textarea) {
});
html += '
';
- previewContainer.innerHTML = html;
+ previewContainer.innerHTML = html;
+ if (inputFeedback) {
+ inputFeedback.textContent = '';
+ inputFeedback.hidden = true;
+ }
// 绑定选择事件
bindURLSelection();
@@ -887,33 +918,29 @@ async function submitTranscription(event) {
return;
}
- const selectedURL = getSelectedURL();
- const useSpeakerRecognition = document.getElementById('speaker-recognition').checked;
+ const selectedURL = getSelectedURL();
+ const useSpeakerRecognition = document.getElementById('speaker-recognition').checked;
const wechatWebhook = document.getElementById('wechat-webhook').value.trim();
const originalText = document.getElementById('share-content').value.trim();
- if (!selectedURL) {
- UIManager.showStatus('error', '请先选择一个视频链接', '请在上方文本框中输入包含视频链接的内容,系统会自动提取并显示可选的链接');
+ if (!selectedURL) {
+ const inputFeedback = document.getElementById('input-feedback');
+ if (inputFeedback) {
+ inputFeedback.textContent = '请先选择一个视频链接。';
+ inputFeedback.hidden = false;
+ }
+ UIManager.showStatus('error', '请先选择一个视频链接', '请在上方文本框中输入包含视频链接的内容,系统会自动提取并显示可选的链接');
setTimeout(UIManager.hideStatus, 5000);
return;
}
const token = StorageManager.get(APP_CONFIG.STORAGE_KEYS.BEARER_TOKEN);
- if (!token) {
- UIManager.showStatus('error', '请先设置访问令牌', '请在高级设置中填写你的访问令牌');
- // 自动展开高级设置
- if (!isAdvancedSettingsExpanded) {
- UIManager.toggleAdvancedSettings();
- }
- // 聚焦到 token 输入框
- setTimeout(() => {
- const tokenInput = document.getElementById('bearer-token');
- if (tokenInput) {
- tokenInput.focus();
- }
- }, 100);
- setTimeout(UIManager.hideStatus, 5000);
- return;
+ if (!token) {
+ const authPrompt = document.getElementById('auth-missing-prompt');
+ if (authPrompt) authPrompt.hidden = false;
+ UIManager.showStatus('error', '请先设置访问令牌', '点击“去设置”展开访问令牌输入框');
+ setTimeout(UIManager.hideStatus, 5000);
+ return;
}
try {
@@ -965,9 +992,16 @@ async function submitTranscription(event) {
UIManager.showStatus('success', statusMessage, statusDetails);
- // 清空表单
- document.getElementById('share-content').value = '';
- document.getElementById('url-preview').innerHTML = '
请输入包含视频链接的内容
';
+ // 清空表单
+ document.getElementById('share-content').value = '';
+ const previewContainer = document.getElementById('url-preview');
+ previewContainer.innerHTML = '';
+ previewContainer.hidden = true;
+ const inputFeedback = document.getElementById('input-feedback');
+ if (inputFeedback) {
+ inputFeedback.textContent = '';
+ inputFeedback.hidden = true;
+ }
// 3秒后跳转到查看页面
// PWA standalone 模式下取消自动跳转(Codex R2-1):/view 的
@@ -986,9 +1020,14 @@ async function submitTranscription(event) {
throw new Error(response.message || '提交失败');
}
- } catch (error) {
- console.error('提交任务失败:', error);
- UIManager.showStatus('error', '提交任务失败', error.message);
+ } catch (error) {
+ console.error('提交任务失败:', error);
+ const inputFeedback = document.getElementById('input-feedback');
+ if (inputFeedback) {
+ inputFeedback.textContent = '提交失败,请检查链接和访问令牌后重试。';
+ inputFeedback.hidden = false;
+ }
+ UIManager.showStatus('error', '提交任务失败', error.message);
} finally {
currentTask = null;
UIManager.updateSubmitButton();
@@ -1028,20 +1067,26 @@ function initializePage() {
textarea.value = ''; // 确保初始为空
textarea.addEventListener('input', () => handleTextInput(textarea));
- // 确保URL预览区域初始状态正确
- const previewContainer = document.getElementById('url-preview');
- previewContainer.innerHTML = '
请输入包含视频链接的内容
';
-
- // 如果没有保存的 token,自动展开高级设置
- if (!savedToken) {
- UIManager.toggleAdvancedSettings();
- }
+ // 空输入时不占用首屏空间
+ const previewContainer = document.getElementById('url-preview');
+ previewContainer.innerHTML = '';
+ previewContainer.hidden = true;
const form = document.getElementById('transcribe-form');
form.addEventListener('submit', submitTranscription);
- const advancedToggle = document.getElementById('advanced-toggle');
- advancedToggle.addEventListener('click', UIManager.toggleAdvancedSettings);
+ const advancedToggle = document.getElementById('advanced-toggle');
+ advancedToggle.addEventListener('click', UIManager.toggleAdvancedSettings);
+
+ const transcriptionOptionsToggle = document.getElementById('transcription-options-toggle');
+ transcriptionOptionsToggle.addEventListener('click', UIManager.toggleTranscriptionOptions);
+
+ const authSettingsLink = document.getElementById('auth-settings-link');
+ authSettingsLink.addEventListener('click', () => {
+ if (!isAdvancedSettingsExpanded) UIManager.toggleAdvancedSettings();
+ const tokenInput = document.getElementById('bearer-token');
+ if (tokenInput) tokenInput.focus();
+ });
const tokenToggle = document.getElementById('toggle-token-visibility');
tokenToggle.addEventListener('click', UIManager.toggleTokenVisibility);
@@ -1080,9 +1125,16 @@ function initializePage() {
}
});
- document.getElementById('speaker-recognition').addEventListener('change', (e) => {
- StorageManager.set(APP_CONFIG.STORAGE_KEYS.SPEAKER_RECOGNITION, e.target.checked);
- });
+ document.getElementById('speaker-recognition').addEventListener('change', (e) => {
+ StorageManager.set(APP_CONFIG.STORAGE_KEYS.SPEAKER_RECOGNITION, e.target.checked);
+ const summary = document.getElementById('transcription-options-summary');
+ if (summary) summary.textContent = e.target.checked ? '已启用说话人识别' : '未启用说话人识别';
+ });
+
+ const speakerSummary = document.getElementById('transcription-options-summary');
+ if (speakerSummary && savedSpeakerRecognition) {
+ speakerSummary.textContent = '已启用说话人识别';
+ }
// 渲染任务历史
TaskHistoryManager.renderHistory();
diff --git a/src/web/tests/mobile-submit-ux.test.js b/src/web/tests/mobile-submit-ux.test.js
new file mode 100644
index 0000000..5179010
--- /dev/null
+++ b/src/web/tests/mobile-submit-ux.test.js
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+// Behavioral contracts for the mobile first-screen submission flow.
+import { readFileSync } from 'node:fs';
+import { JSDOM } from 'jsdom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const indexSource = readFileSync(
+ 'src/web/static/index.html',
+ 'utf-8'
+);
+const authSource = readFileSync('src/web/static/js/auth-storage.js', 'utf-8');
+const appSource = readFileSync('src/web/static/js/app.js', 'utf-8');
+const pwaShareSource = readFileSync('src/web/static/js/pwa-share.js', 'utf-8');
+const pwaSource = readFileSync('src/web/static/pwa.js', 'utf-8');
+
+let dom;
+
+async function installPage({ token = null, search = '' } = {}) {
+ dom = new JSDOM(indexSource, {
+ url: `https://vta.test/add_task_by_web${search}`,
+ runScripts: 'outside-only',
+ });
+ const { window } = dom;
+ dom.window.HTMLElement.prototype.scrollIntoView = vi.fn();
+ window.matchMedia = vi.fn((media) => ({
+ matches: false,
+ media,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+ const initializeLog = vi.spyOn(window.console, 'log');
+ window.eval(authSource);
+ if (token) window.VideoTranscriptAuthStorage.writeAuthToken(token, { remember: true });
+ window.eval(appSource);
+ window.eval(pwaShareSource);
+ window.eval(pwaSource);
+ await new Promise((resolve) => {
+ window.addEventListener('DOMContentLoaded', resolve, { once: true });
+ });
+ return initializeLog;
+}
+
+function inputShareText(value) {
+ const textarea = dom.window.document.getElementById('share-content');
+ textarea.value = value;
+ textarea.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
+}
+
+afterEach(() => {
+ vi.clearAllTimers();
+ dom?.window.close();
+ vi.restoreAllMocks();
+});
+
+describe('mobile first-screen submission', () => {
+ it('enables CTA and hides auth prompt for a saved token and valid URL', async () => {
+ await installPage({ token: 'saved-token' });
+ inputShareText('https://www.youtube.com/watch?v=mobile-test');
+
+ expect(dom.window.document.getElementById('submit-btn').disabled).toBe(false);
+ expect(dom.window.document.getElementById('auth-missing-prompt').hidden).toBe(true);
+ expect(dom.window.document.getElementById('advanced-settings').hidden).toBe(true);
+ });
+
+ it('shows the missing-token prompt and keeps CTA disabled', async () => {
+ const initializeLog = await installPage();
+ const page = dom.window.document;
+ expect(page.getElementById('auth-missing-prompt').hidden).toBe(false);
+ expect(page.getElementById('submit-btn').disabled).toBe(true);
+ expect(initializeLog.mock.calls.filter(([message]) => message === '初始化视频转录Web应用...')).toHaveLength(1);
+ });
+
+ it('shows polite inline feedback for invalid input without a URL preview', async () => {
+ await installPage();
+ inputShareText('这不是视频链接');
+
+ const feedback = dom.window.document.getElementById('input-feedback');
+ expect(feedback.hidden).toBe(false);
+ expect(feedback.getAttribute('aria-live')).toBe('polite');
+ expect(feedback.textContent).toContain('未检测到可识别的视频链接');
+ expect(dom.window.document.getElementById('url-preview').hidden).toBe(true);
+ });
+
+ it('updates aria-expanded for transcription options and advanced settings', async () => {
+ await installPage();
+ const optionsToggle = dom.window.document.getElementById('transcription-options-toggle');
+ const advancedToggle = dom.window.document.getElementById('advanced-toggle');
+
+ optionsToggle.click();
+ advancedToggle.click();
+ expect(optionsToggle.getAttribute('aria-expanded')).toBe('true');
+ expect(advancedToggle.getAttribute('aria-expanded')).toBe('true');
+
+ optionsToggle.click();
+ advancedToggle.click();
+ expect(optionsToggle.getAttribute('aria-expanded')).toBe('false');
+ expect(advancedToggle.getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it('prefills a PWA URL, then enables CTA after token input without editing the URL', async () => {
+ await installPage({ search: '?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dpwa-test' });
+ const page = dom.window.document;
+ const textarea = page.getElementById('share-content');
+ const tokenInput = page.getElementById('bearer-token');
+
+ expect(textarea.value).toBe('https://www.youtube.com/watch?v=pwa-test');
+ expect(page.getElementById('submit-btn').disabled).toBe(true);
+ expect(page.getElementById('auth-missing-prompt').hidden).toBe(false);
+
+ tokenInput.value = 'pwa-token';
+ tokenInput.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
+
+ expect(textarea.value).toBe('https://www.youtube.com/watch?v=pwa-test');
+ expect(page.getElementById('auth-missing-prompt').hidden).toBe(true);
+ expect(page.getElementById('submit-btn').disabled).toBe(false);
+ });
+});
diff --git a/tests/unit/web/test_frontend_mobile_submit.py b/tests/unit/web/test_frontend_mobile_submit.py
new file mode 100644
index 0000000..91f643d
--- /dev/null
+++ b/tests/unit/web/test_frontend_mobile_submit.py
@@ -0,0 +1,65 @@
+"""Structural contracts for the mobile first-screen submission flow."""
+
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[3]
+STATIC_DIR = PROJECT_ROOT / "src" / "web" / "static"
+INDEX_HTML = STATIC_DIR / "index.html"
+APP_JS = STATIC_DIR / "js" / "app.js"
+STYLES_CSS = STATIC_DIR / "css" / "styles.css"
+
+
+def test_mobile_form_puts_options_auth_prompt_and_cta_in_first_screen_order():
+ html = INDEX_HTML.read_text(encoding="utf-8")
+
+ positions = [
+ html.index('id="share-content"'),
+ html.index('id="url-preview"'),
+ html.index('id="transcription-options-toggle"'),
+ html.index('id="auth-missing-prompt"'),
+ html.index('id="submit-btn"'),
+ html.index('id="advanced-settings"'),
+ ]
+ assert positions == sorted(positions)
+ assert html.index('id="advanced-settings"') > html.index('id="submit-btn"')
+
+
+def test_mobile_header_keeps_desktop_home_and_history_navigation():
+ html = INDEX_HTML.read_text(encoding="utf-8")
+ nav = html.split('
", 1)[0]
+
+ assert 'href="/"' in nav
+ assert 'href="/static/history.html"' in nav
+ assert 'id="pwa-install-btn"' in nav
+
+ mobile_css = STYLES_CSS.read_text(encoding="utf-8").split("@media (max-width: 480px)", 1)[1]
+ assert '.site-nav-link[href="/"]' in mobile_css
+ assert 'display: none' in mobile_css
+
+
+def test_mobile_controls_expose_expansion_and_live_feedback_contracts():
+ html = INDEX_HTML.read_text(encoding="utf-8")
+
+ assert 'aria-live="polite"' in html
+ assert 'aria-controls="transcription-options"' in html
+ assert 'aria-controls="advanced-settings"' in html
+ assert 'aria-expanded="false"' in html
+ assert '尚未配置访问令牌' in html
+ assert '去设置' in html
+
+
+def test_empty_preview_is_hidden_and_invalid_input_has_inline_feedback():
+ html = INDEX_HTML.read_text(encoding="utf-8")
+ app = APP_JS.read_text(encoding="utf-8")
+
+ assert 'id="url-preview" class="url-preview"' in html
+ assert 'aria-live="polite" hidden' in html
+ assert 'id="input-feedback"' in html
+ assert 'class="input-feedback"' in html
+ assert 'inputmode="url"' not in html
+ assert 'autocomplete="url"' not in html
+ assert 'enterkeyhint="go"' not in html
+ assert "previewContainer.hidden = urlResults.length === 0" in app
+ assert "inputFeedback.textContent" in app
+ assert "url-preview').innerHTML = '
请输入包含视频链接的内容
'" not in app