diff --git a/app/appeal.tsx b/app/appeal.tsx index 4c24efd..258c57c 100644 --- a/app/appeal.tsx +++ b/app/appeal.tsx @@ -322,6 +322,7 @@ export default function AppealScreen() { const handleSubmit = async () => { const result = await submit(); if (result) { + // TODO: 提交成功后跳转结果页或关闭弹层 } }; diff --git a/app/game/level.tsx b/app/game/level.tsx index 94d415e..42d386e 100644 --- a/app/game/level.tsx +++ b/app/game/level.tsx @@ -134,7 +134,7 @@ export default function GameLevelScreen() { setAnswers((prev) => { const next = prev.filter((a) => a.questionId !== questionId); const existing = prev.find((a) => a.questionId === questionId); - let selectedIds: string[] = []; + let selectedIds: string[]; if (type === 'multiple') { const current = existing?.selectedIds ?? []; selectedIds = current.includes(optionId) diff --git a/app/legal.tsx b/app/legal.tsx index ca34df3..80e8072 100644 --- a/app/legal.tsx +++ b/app/legal.tsx @@ -148,7 +148,9 @@ export default function LegalScreen() { } else if (service.action === 'templates') { // 模板在首页已展示,滚动到模板区更自然;这里简单提示 } else if (service.action === 'faq') { + // TODO: 接入 FAQ 列表页 } else if (service.action === 'upgrade') { + // TODO: 接入付费升级入口 } }; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5fbe923 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,97 @@ +const js = require('@eslint/js'); +const tseslint = require('typescript-eslint'); +const react = require('eslint-plugin-react'); +const reactHooks = require('eslint-plugin-react-hooks'); +const globals = require('globals'); + +/** + * 自定义规则:禁止在组件/业务代码内写死十六进制颜色字面量。 + * 对齐 AGENTS.md 配色禁令——颜色必须统一从 src/theme/colors.ts 导入。 + * 例外:theme/colors.ts 本身是设计令牌唯一来源,豁免。 + */ +const noHardcodedHexColor = { + meta: { + type: 'problem', + docs: { + description: '禁止在组件内写死十六进制颜色,统一从 src/theme/colors.ts 导入', + }, + schema: [], + messages: { + noHex: + '禁止写死十六进制颜色字面量 "{{value}}",请从 src/theme/colors.ts 导入颜色 token。', + }, + }, + create(context) { + const hexRe = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + return { + Literal(node) { + if (typeof node.value === 'string' && hexRe.test(node.value)) { + context.report({ node, messageId: 'noHex', data: { value: node.value } }); + } + }, + }; + }, +}; + +module.exports = tseslint.config( + { + ignores: [ + 'node_modules/**', + '.expo/**', + 'dist/**', + 'coverage/**', + 'figma_out/**', + '*.mjs', + 'figma_discover.py', + '**/__tests__/**', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + plugins: { + react, + 'react-hooks': reactHooks, + local: { rules: { 'no-hardcoded-hex-color': noHardcodedHexColor } }, + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + sourceType: 'module', + globals: { ...globals.node, ...globals.react }, + }, + settings: { react: { version: 'detect' } }, + rules: { + ...react.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + 'react/jsx-uses-react': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'local/no-hardcoded-hex-color': 'error', + }, + }, + { + // 项目配置文件为 CommonJS,含 require / module / process + files: ['eslint.config.js', 'babel.config.js', 'jest.config.js', 'app.config.js', '*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { ...globals.node }, + }, + rules: { + 'no-undef': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + // 设计令牌文件是颜色唯一来源,豁免 hex 检查 + files: ['**/theme/**/*.ts'], + rules: { 'local/no-hardcoded-hex-color': 'off' }, + }, +); diff --git a/package.json b/package.json index 292b726..28c2636 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,25 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "jest" }, "private": true, "devDependencies": { + "@eslint/js": "latest", + "@react-native/jest-preset": "0.86.2", + "@types/jest": "^29.5.12", "@types/react": "~19.2.4", - "typescript": "~6.0.3" + "babel-preset-expo": "~57.0.7", + "eslint": "^9.1.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "globals": "latest", + "jest": "^29.7.0", + "jest-expo": "~57.0.0", + "typescript": "~6.0.3", + "typescript-eslint": "latest" } } diff --git a/src/__tests__/storage.test.ts b/src/__tests__/storage.test.ts new file mode 100644 index 0000000..45a0246 --- /dev/null +++ b/src/__tests__/storage.test.ts @@ -0,0 +1,38 @@ +jest.mock('@react-native-async-storage/async-storage', () => { + const store = new Map(); + const impl = { + setItem: jest.fn(async (k: string, v: string) => { + store.set(k, v); + }), + getItem: jest.fn(async (k: string) => (store.has(k) ? (store.get(k) as string) : null)), + removeItem: jest.fn(async (k: string) => { + store.delete(k); + }), + clear: jest.fn(async () => store.clear()), + getAllKeys: jest.fn(async () => Array.from(store.keys())), + }; + return { __esModule: true, default: impl, ...impl }; +}); + +import { storageGet, storageSet, storageRemove } from '../lib/storage'; + +describe('storage 封装(async-storage 轻量持久化)', () => { + beforeEach(async () => { + await storageRemove('ut_token'); + }); + + it('set 后 get 可回读,且键自动加前缀', async () => { + await storageSet('ut_token', 'abc123'); + expect(await storageGet('ut_token')).toBe('abc123'); + }); + + it('remove 后 get 返回 null', async () => { + await storageSet('ut_token', 'xyz'); + await storageRemove('ut_token'); + expect(await storageGet('ut_token')).toBeNull(); + }); + + it('读取不存在的键返回 null', async () => { + expect(await storageGet('ut_missing_key')).toBeNull(); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 09f2e64..61c44a1 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3749,7 +3749,7 @@ export async function sendLegalMessage(content: string): Promise /* ===================== 设置:隐私设置 ===================== */ /** 隐私开关(内存态,mock 阶段刷新后重置) */ -let MOCK_PRIVACY_SETTINGS: ToggleSetting[] = [ +const MOCK_PRIVACY_SETTINGS: ToggleSetting[] = [ { id: 'resume-visible', title: '简历对 HR 可见', @@ -4597,7 +4597,7 @@ export async function requestPersonalDataExport(): Promise<{ success: boolean; m /* ===================== 设置:消息通知 ===================== */ /** 通知开关(内存态) */ -let MOCK_NOTIFICATION_SETTINGS: ToggleSetting[] = [ +const MOCK_NOTIFICATION_SETTINGS: ToggleSetting[] = [ { id: 'interview-remind', title: '面试提醒', diff --git a/src/hooks/useGameData.ts b/src/hooks/useGameData.ts index b4b9fbb..a5babfd 100644 --- a/src/hooks/useGameData.ts +++ b/src/hooks/useGameData.ts @@ -57,7 +57,7 @@ export function useGameData(): GameDataState { const next = await submitGameLevel(payload); refreshProgress(); return next; - } catch (err) { + } catch { return null; } finally { setSubmitting(false);