fix:优化数据

This commit is contained in:
丹尼尔
2026-03-15 16:38:59 +08:00
parent a609f81a36
commit 3aa1a586e5
43 changed files with 14565 additions and 294 deletions

View File

@@ -1,14 +1,19 @@
# 分层构建:依赖与代码分离,仅代码变更时只重建最后一层,加快迭代
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim
ENV MODULE_NAME=backend.app.main
ENV VARIABLE_NAME=app
ENV PORT=8000
ENV HOST=0.0.0.0
# SQLite 对并发写不友好,单 worker 避免多进程竞争
ENV WEB_CONCURRENCY=1
WORKDIR /app
# 依赖层:只有 requirements 变更时才重建
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# 代码层:仅此层会随业务代码变更而重建
COPY backend /app/backend

901
backend.log Normal file
View File

@@ -0,0 +1,901 @@
INFO: Will watch for changes in these directories: ['/Users/dannier/Desktop/living/AiTool']
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started reloader process [85187] using WatchFiles
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/conftest.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_where.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_repr.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_reduction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_concat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_reshape.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_dtypes.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_comparison.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_function.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/base_class/test_formats.py'. Reloading...
Process SpawnProcess-1:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_libs/window/__init__.py'. Reloading...
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_freq_attr.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_setops.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_legend.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_delete.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta_range.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_formats.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_searchsorted.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_ops.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_timedelta.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_subplots.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_scalar_compat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/string/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_join.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/test_pickle.py', '.venv/lib/python3.9/site-packages/pandas/_libs/tslibs/__init__.py'. Reloading...
Process SpawnProcess-3:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_categorical.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_api.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply_mutate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_dropna.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame_color.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_grouping.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_libgroupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_timegrouper.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_reductions.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_numeric_only.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_all_methods.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_frame.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_apply.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_index_as_string.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_pipe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/conftest.py'. Reloading...
Process SpawnProcess-4:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_indexing.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby_subclass.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_groupby.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_counting.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/multi/test_constructors.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_construction.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/test_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_filters.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexing/multiindex/test_chaining_and_caching.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_raises.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_cumulative.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_equals.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/numpy_/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_missing.py'. Reloading...
Process SpawnProcess-5:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_fillna.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_insert.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_is_monotonic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_corrwith.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_sample.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/arrays/integer/test_arithmetic.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_value_counts.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_quantile.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_skew.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/transform/test_transform.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_cython.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_describe.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_factorize.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_other.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_numba.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nth.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/aggregate/test_aggregate.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_groupby_shift_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_rank.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/methods/test_nlargest_nsmallest.py', '.venv/lib/python3.9/site-packages/pandas/tests/tslibs/test_fields.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_repeat.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/timedeltas/methods/test_shift.py'. Reloading...
Process SpawnProcess-6:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/attr/_version_info.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/_core.py', '.venv/lib/python3.9/site-packages/mdurl/__init__.py', '.venv/lib/python3.9/site-packages/attr/_next_gen.py', '.venv/lib/python3.9/site-packages/mdurl/_encode.py', '.venv/lib/python3.9/site-packages/attr/exceptions.py', '.venv/lib/python3.9/site-packages/mdurl/_parse.py', '.venv/lib/python3.9/site-packages/mdurl/_url.py', '.venv/lib/python3.9/site-packages/attr/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_format.py', '.venv/lib/python3.9/site-packages/attr/_compat.py', '.venv/lib/python3.9/site-packages/attr/converters.py', '.venv/lib/python3.9/site-packages/attr/_funcs.py', '.venv/lib/python3.9/site-packages/attr/validators.py', '.venv/lib/python3.9/site-packages/attr/filters.py', '.venv/lib/python3.9/site-packages/attr/_make.py', '.venv/lib/python3.9/site-packages/attr/setters.py', '.venv/lib/python3.9/site-packages/attr/_config.py', '.venv/lib/python3.9/site-packages/six.py', '.venv/lib/python3.9/site-packages/jsonschema_specifications/__init__.py', '.venv/lib/python3.9/site-packages/mdurl/_decode.py', '.venv/lib/python3.9/site-packages/rpds/__init__.py'. Reloading...
Process SpawnProcess-7:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Process SpawnProcess-8:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/_config/dates.py', '.venv/lib/python3.9/site-packages/pandas/tests/groupby/test_bin_groupby.py', '.venv/lib/python3.9/site-packages/mako/codegen.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_getitem.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_size.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_diff.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/test_hist_box_by.py', '.venv/lib/python3.9/site-packages/pandas/tests/construction/__init__.py', '.venv/lib/python3.9/site-packages/pandas/tests/indexes/object/test_astype.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/methods/test_to_numpy.py', '.venv/lib/python3.9/site-packages/pandas/tests/plotting/frame/__init__.py', '.venv/lib/python3.9/site-packages/mako/exceptions.py', '.venv/lib/python3.9/site-packages/pandas/tests/frame/indexing/test_where.py', '.venv/lib/python3.9/site-packages/pandas/_config/localization.py', '.venv/lib/python3.9/site-packages/mako/lexer.py'. Reloading...
Process SpawnProcess-9:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/pandas/api/indexers/__init__.py', '.venv/lib/python3.9/site-packages/pandas/errors/__init__.py', '.venv/lib/python3.9/site-packages/lxml/ElementInclude.py'. Reloading...
Process SpawnProcess-10:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_content.py', '.venv/lib/python3.9/site-packages/mako/pyparser.py', '.venv/lib/python3.9/site-packages/mako/parsetree.py', '.venv/lib/python3.9/site-packages/mako/testing/_config.py', '.venv/lib/python3.9/site-packages/mako/_ast_util.py', '.venv/lib/python3.9/site-packages/lxml/sax.py', '.venv/lib/python3.9/site-packages/lxml/html/usedoctest.py', '.venv/lib/python3.9/site-packages/mako/testing/config.py', '.venv/lib/python3.9/site-packages/mako/testing/exclusions.py', '.venv/lib/python3.9/site-packages/mako/pygen.py', '.venv/lib/python3.9/site-packages/mako/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/__init__.py', '.venv/lib/python3.9/site-packages/mako/filters.py', '.venv/lib/python3.9/site-packages/mako/testing/fixtures.py', '.venv/lib/python3.9/site-packages/httpx/_types.py', '.venv/lib/python3.9/site-packages/httpx/_decoders.py', '.venv/lib/python3.9/site-packages/mako/cmd.py', '.venv/lib/python3.9/site-packages/mako/testing/assertions.py', '.venv/lib/python3.9/site-packages/httpx/_client.py', '.venv/lib/python3.9/site-packages/httpx/_urlparse.py', '.venv/lib/python3.9/site-packages/lxml/html/_setmixin.py', '.venv/lib/python3.9/site-packages/mako/lookup.py', '.venv/lib/python3.9/site-packages/mako/cache.py', '.venv/lib/python3.9/site-packages/httpx/_config.py', '.venv/lib/python3.9/site-packages/httptools/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_models.py', '.venv/lib/python3.9/site-packages/lxml/html/defs.py', '.venv/lib/python3.9/site-packages/httpx/__init__.py', '.venv/lib/python3.9/site-packages/mako/testing/helpers.py', '.venv/lib/python3.9/site-packages/mako/util.py', '.venv/lib/python3.9/site-packages/lxml/html/_difflib.py', '.venv/lib/python3.9/site-packages/mako/runtime.py', '.venv/lib/python3.9/site-packages/httpx/_status_codes.py', '.venv/lib/python3.9/site-packages/mako/template.py', '.venv/lib/python3.9/site-packages/mako/ast.py', '.venv/lib/python3.9/site-packages/mako/compat.py', '.venv/lib/python3.9/site-packages/lxml/doctestcompare.py', '.venv/lib/python3.9/site-packages/lxml/pyclasslookup.py', '.venv/lib/python3.9/site-packages/httpx/_auth.py'. Reloading...
Process SpawnProcess-11:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/mako/ext/preprocessors.py', '.venv/lib/python3.9/site-packages/mako/ext/__init__.py', '.venv/lib/python3.9/site-packages/lxml/_elementpath.py', '.venv/lib/python3.9/site-packages/mako/ext/autohandler.py', '.venv/lib/python3.9/site-packages/lxml/html/soupparser.py', '.venv/lib/python3.9/site-packages/lxml/html/html5parser.py', '.venv/lib/python3.9/site-packages/mako/ext/extract.py', '.venv/lib/python3.9/site-packages/lxml/html/ElementSoup.py', '.venv/lib/python3.9/site-packages/cachetools/func.py', '.venv/lib/python3.9/site-packages/cachetools/keys.py', '.venv/lib/python3.9/site-packages/mako/ext/beaker_cache.py', '.venv/lib/python3.9/site-packages/mako/ext/babelplugin.py', '.venv/lib/python3.9/site-packages/cachetools/_decorators.py', '.venv/lib/python3.9/site-packages/mako/ext/pygmentplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/formfill.py', '.venv/lib/python3.9/site-packages/lxml/html/clean.py', '.venv/lib/python3.9/site-packages/jiter/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/protocol.py', '.venv/lib/python3.9/site-packages/mako/ext/turbogears.py', '.venv/lib/python3.9/site-packages/mako/ext/linguaplugin.py', '.venv/lib/python3.9/site-packages/lxml/html/_diffcommand.py', '.venv/lib/python3.9/site-packages/cachetools/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/diff.py'. Reloading...
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/altair/theme.py'. Reloading...
Process SpawnProcess-12:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/lxml/usedoctest.py', '.venv/lib/python3.9/site-packages/altair/typing/__init__.py', '.venv/lib/python3.9/site-packages/lxml/includes/extlibs/__init__.py', '.venv/lib/python3.9/site-packages/httptools/parser/errors.py', '.venv/lib/python3.9/site-packages/httpx/_exceptions.py', '.venv/lib/python3.9/site-packages/lxml/isoschematron/__init__.py', '.venv/lib/python3.9/site-packages/lxml/__init__.py'. Reloading...
Process SpawnProcess-13:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Process SpawnProcess-14:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_multipart.py', '.venv/lib/python3.9/site-packages/httpx/_main.py', '.venv/lib/python3.9/site-packages/lxml/builder.py', '.venv/lib/python3.9/site-packages/httptools/parser/__init__.py', '.venv/lib/python3.9/site-packages/lxml/cssselect.py', '.venv/lib/python3.9/site-packages/httpx/_api.py', '.venv/lib/python3.9/site-packages/httpx/__version__.py', '.venv/lib/python3.9/site-packages/httpx/_utils.py', '.venv/lib/python3.9/site-packages/lxml/includes/__init__.py', '.venv/lib/python3.9/site-packages/lxml/html/_html5builder.py', '.venv/lib/python3.9/site-packages/httpx/_compat.py'. Reloading...
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/sjisprober.py', '.venv/lib/python3.9/site-packages/chardet/jisfreq.py', '.venv/lib/python3.9/site-packages/chardet/resultdict.py', '.venv/lib/python3.9/site-packages/chardet/sbcharsetprober.py', '.venv/lib/python3.9/site-packages/chardet/escprober.py', '.venv/lib/python3.9/site-packages/chardet/version.py', '.venv/lib/python3.9/site-packages/lxml/includes/libexslt/__init__.py', '.venv/lib/python3.9/site-packages/chardet/langgreekmodel.py', '.venv/lib/python3.9/site-packages/chardet/codingstatemachine.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxslt/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/__init__.py'. Reloading...
Process SpawnProcess-15:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Process SpawnProcess-16:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/httpx/_urls.py', '.venv/lib/python3.9/site-packages/streamlit/error_util.py', '.venv/lib/python3.9/site-packages/lxml/html/builder.py', '.venv/lib/python3.9/site-packages/git/index/fun.py', '.venv/lib/python3.9/site-packages/chardet/universaldetector.py', '.venv/lib/python3.9/site-packages/streamlit/__init__.py', '.venv/lib/python3.9/site-packages/httptools/_version.py', '.venv/lib/python3.9/site-packages/chardet/big5prober.py', '.venv/lib/python3.9/site-packages/altair/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/url_util.py', '.venv/lib/python3.9/site-packages/chardet/langrussianmodel.py', '.venv/lib/python3.9/site-packages/altair/_magics.py'. Reloading...
Process SpawnProcess-17:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/utf8prober.py', '.venv/lib/python3.9/site-packages/chardet/latin1prober.py', '.venv/lib/python3.9/site-packages/altair/utils/plugin_registry.py', '.venv/lib/python3.9/site-packages/altair/utils/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/mimebundle.py', '.venv/lib/python3.9/site-packages/chardet/charsetprober.py', '.venv/lib/python3.9/site-packages/chardet/jpcntx.py', '.venv/lib/python3.9/site-packages/chardet/gb2312freq.py', '.venv/lib/python3.9/site-packages/chardet/langhebrewmodel.py', '.venv/lib/python3.9/site-packages/altair/expr/core.py', '.venv/lib/python3.9/site-packages/altair/utils/html.py', '.venv/lib/python3.9/site-packages/chardet/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcharsetprober.py', '.venv/lib/python3.9/site-packages/altair/utils/display.py', '.venv/lib/python3.9/site-packages/httpx/_transports/wsgi.py', '.venv/lib/python3.9/site-packages/altair/utils/selection.py', '.venv/lib/python3.9/site-packages/altair/expr/consts.py', '.venv/lib/python3.9/site-packages/altair/jupyter/jupyter_chart.py', '.venv/lib/python3.9/site-packages/altair/utils/server.py', '.venv/lib/python3.9/site-packages/altair/expr/__init__.py', '.venv/lib/python3.9/site-packages/httpx/_transports/asgi.py', '.venv/lib/python3.9/site-packages/httpx/_transports/default.py', '.venv/lib/python3.9/site-packages/altair/utils/deprecation.py', '.venv/lib/python3.9/site-packages/altair/utils/_transformed_data.py', '.venv/lib/python3.9/site-packages/chardet/__main__.py', '.venv/lib/python3.9/site-packages/chardet/big5freq.py', '.venv/lib/python3.9/site-packages/chardet/langhungarianmodel.py', '.venv/lib/python3.9/site-packages/httpx/_transports/__init__.py', '.venv/lib/python3.9/site-packages/chardet/euckrprober.py', '.venv/lib/python3.9/site-packages/chardet/langthaimodel.py', '.venv/lib/python3.9/site-packages/chardet/gb2312prober.py', '.venv/lib/python3.9/site-packages/altair/utils/schemapi.py', '.venv/lib/python3.9/site-packages/altair/utils/_dfi_types.py', '.venv/lib/python3.9/site-packages/chardet/johabprober.py', '.venv/lib/python3.9/site-packages/chardet/euctwfreq.py', '.venv/lib/python3.9/site-packages/chardet/euctwprober.py', '.venv/lib/python3.9/site-packages/altair/jupyter/__init__.py', '.venv/lib/python3.9/site-packages/altair/utils/_importers.py', '.venv/lib/python3.9/site-packages/chardet/charsetgroupprober.py', '.venv/lib/python3.9/site-packages/httpx/_transports/base.py', '.venv/lib/python3.9/site-packages/altair/utils/compiler.py', '.venv/lib/python3.9/site-packages/altair/utils/_vegafusion_data.py', '.venv/lib/python3.9/site-packages/chardet/sbcsgroupprober.py', '.venv/lib/python3.9/site-packages/altair/utils/data.py', '.venv/lib/python3.9/site-packages/altair/expr/funcs.py', '.venv/lib/python3.9/site-packages/chardet/euckrfreq.py', '.venv/lib/python3.9/site-packages/chardet/escsm.py', '.venv/lib/python3.9/site-packages/altair/utils/execeval.py', '.venv/lib/python3.9/site-packages/altair/utils/save.py', '.venv/lib/python3.9/site-packages/chardet/langturkishmodel.py', '.venv/lib/python3.9/site-packages/altair/utils/core.py', '.venv/lib/python3.9/site-packages/altair/utils/_show.py', '.venv/lib/python3.9/site-packages/chardet/johabfreq.py', '.venv/lib/python3.9/site-packages/httpx/_transports/mock.py', '.venv/lib/python3.9/site-packages/chardet/langbulgarianmodel.py', '.venv/lib/python3.9/site-packages/chardet/utf1632prober.py'. Reloading...
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/codingstatemachinedict.py', '.venv/lib/python3.9/site-packages/git/config.py', '.venv/lib/python3.9/site-packages/chardet/macromanprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/incremental_tree.py', '.venv/lib/python3.9/site-packages/chardet/chardistribution.py', '.venv/lib/python3.9/site-packages/altair/vegalite/api.py', '.venv/lib/python3.9/site-packages/distro/distro.py', '.venv/lib/python3.9/site-packages/et_xmlfile/__init__.py', '.venv/lib/python3.9/site-packages/chardet/mbcssm.py', '.venv/lib/python3.9/site-packages/distro/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cp949prober.py', '.venv/lib/python3.9/site-packages/altair/vegalite/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/schema.py', '.venv/lib/python3.9/site-packages/distro/__main__.py', '.venv/lib/python3.9/site-packages/chardet/enums.py', '.venv/lib/python3.9/site-packages/altair/vegalite/display.py', '.venv/lib/python3.9/site-packages/chardet/mbcsgroupprober.py', '.venv/lib/python3.9/site-packages/et_xmlfile/xmlfile.py', '.venv/lib/python3.9/site-packages/chardet/eucjpprober.py', '.venv/lib/python3.9/site-packages/chardet/hebrewprober.py'. Reloading...
Process SpawnProcess-18:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/__init__.py', '.venv/lib/python3.9/site-packages/git/types.py', '.venv/lib/python3.9/site-packages/git/diff.py', '.venv/lib/python3.9/site-packages/git/cmd.py', '.venv/lib/python3.9/site-packages/git/exc.py', '.venv/lib/python3.9/site-packages/git/util.py', '.venv/lib/python3.9/site-packages/lxml/includes/libxml/__init__.py', '.venv/lib/python3.9/site-packages/git/remote.py', '.venv/lib/python3.9/site-packages/git/compat.py', '.venv/lib/python3.9/site-packages/git/db.py'. Reloading...
Process SpawnProcess-19:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Process SpawnProcess-20:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/chardet/metadata/__init__.py', '.venv/lib/python3.9/site-packages/chardet/metadata/languages.py', '.venv/lib/python3.9/site-packages/git/refs/symbolic.py', '.venv/lib/python3.9/site-packages/git/refs/reference.py', '.venv/lib/python3.9/site-packages/git/repo/fun.py', '.venv/lib/python3.9/site-packages/git/refs/remote.py', '.venv/lib/python3.9/site-packages/git/repo/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/head.py', '.venv/lib/python3.9/site-packages/git/objects/blob.py', '.venv/lib/python3.9/site-packages/git/objects/tag.py', '.venv/lib/python3.9/site-packages/chardet/cli/chardetect.py', '.venv/lib/python3.9/site-packages/git/refs/log.py', '.venv/lib/python3.9/site-packages/git/refs/tag.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/theme.py', '.venv/lib/python3.9/site-packages/git/objects/__init__.py', '.venv/lib/python3.9/site-packages/chardet/cli/__init__.py', '.venv/lib/python3.9/site-packages/git/refs/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/api.py', '.venv/lib/python3.9/site-packages/git/objects/fun.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/channels.py', '.venv/lib/python3.9/site-packages/git/objects/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/data.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/compiler.py', '.venv/lib/python3.9/site-packages/git/objects/commit.py', '.venv/lib/python3.9/site-packages/git/objects/tree.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/display.py', '.venv/lib/python3.9/site-packages/git/repo/__init__.py', '.venv/lib/python3.9/site-packages/git/objects/util.py'. Reloading...
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/git/index/__init__.py', '.venv/lib/python3.9/site-packages/blinker/base.py', '.venv/lib/python3.9/site-packages/git/index/typ.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/core.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/mixins.py', '.venv/lib/python3.9/site-packages/git/index/base.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_typing.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/__init__.py', '.venv/lib/python3.9/site-packages/altair/vegalite/v5/schema/_config.py', '.venv/lib/python3.9/site-packages/git/index/util.py'. Reloading...
Process SpawnProcess-21:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/exception.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/subtitle_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/streamlit_plotly_theme.py', '.venv/lib/python3.9/site-packages/streamlit/config_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/options_selector_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/code.py', '.venv/lib/python3.9/site-packages/streamlit/elements/balloons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/mutable_status_container.py', '.venv/lib/python3.9/site-packages/streamlit/elements/write.py', '.venv/lib/python3.9/site-packages/streamlit/hello/mapping_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/policies.py', '.venv/lib/python3.9/site-packages/streamlit/development.py', '.venv/lib/python3.9/site-packages/streamlit/type_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/form_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/vega_charts.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/page.py', '.venv/lib/python3.9/site-packages/streamlit/elements/plotly_chart.py', '.venv/lib/python3.9/site-packages/streamlit/hello/utils.py', '.venv/lib/python3.9/site-packages/streamlit/external/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/heading.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/js_number.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/column_config_utils.py', '.venv/lib/python3.9/site-packages/streamlit/elements/alert.py', '.venv/lib/python3.9/site-packages/streamlit/time_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/pyplot.py', '.venv/lib/python3.9/site-packages/streamlit/hello/dataframe_demo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/snow.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator_singletons.py', '.venv/lib/python3.9/site-packages/streamlit/elements/arrow.py', '.venv/lib/python3.9/site-packages/streamlit/elements/form.py', '.venv/lib/python3.9/site-packages/streamlit/deprecation_util.py', '.venv/lib/python3.9/site-packages/streamlit/temporary_directory.py', '.venv/lib/python3.9/site-packages/blinker/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/hello/plotting_demo.py', '.venv/lib/python3.9/site-packages/streamlit/cursor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/color_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/dialog_decorator.py', '.venv/lib/python3.9/site-packages/streamlit/commands/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/json.py', '.venv/lib/python3.9/site-packages/streamlit/hello/hello.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/pandas_styler_utils.py', '.venv/lib/python3.9/site-packages/streamlit/navigation/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/version.py', '.venv/lib/python3.9/site-packages/streamlit/elements/toast.py', '.venv/lib/python3.9/site-packages/streamlit/config_option.py', '.venv/lib/python3.9/site-packages/streamlit/emojis.py', '.venv/lib/python3.9/site-packages/streamlit/commands/logo.py', '.venv/lib/python3.9/site-packages/streamlit/elements/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/logger.py', '.venv/lib/python3.9/site-packages/blinker/_utilities.py', '.venv/lib/python3.9/site-packages/streamlit/elements/bokeh_chart.py', '.venv/lib/python3.9/site-packages/streamlit/material_icon_names.py', '.venv/lib/python3.9/site-packages/streamlit/elements/text.py', '.venv/lib/python3.9/site-packages/streamlit/__main__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/graphviz_chart.py', '.venv/lib/python3.9/site-packages/streamlit/file_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/html.py', '.venv/lib/python3.9/site-packages/streamlit/string_util.py', '.venv/lib/python3.9/site-packages/streamlit/dataframe_util.py', '.venv/lib/python3.9/site-packages/streamlit/elements/spinner.py', '.venv/lib/python3.9/site-packages/streamlit/env_util.py', '.venv/lib/python3.9/site-packages/streamlit/delta_generator.py', '.venv/lib/python3.9/site-packages/streamlit/elements/progress.py'. Reloading...
Process SpawnProcess-22:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Process SpawnProcess-23:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
WARNING: WatchFiles detected changes in '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/select_slider.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_storage.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/file_uploader.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/credentials.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/radio.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/audio_input.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/data_editor.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/time_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_uploaded_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/text_widgets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/connection_factory.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/fragment.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/pages_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/media_file_manager.py', '.venv/lib/python3.9/site-packages/streamlit/elements/lib/dialog.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/number_input.py', '.venv/lib/python3.9/site-packages/streamlit/commands/experimental_query_params.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/runtime_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/metrics_util.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/elements/metric.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/checkbox.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/forward_msg_cache.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/chat.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/script_data.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/__init__.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/memory_session_storage.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/websocket_session_manager.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/stats.py', '.venv/lib/python3.9/site-packages/streamlit/elements/widgets/button_group.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/secrets.py', '.venv/lib/python3.9/site-packages/streamlit/runtime/uploaded_file_manager.py'. Reloading...
Process SpawnProcess-24:
Traceback (most recent call last):
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
self.run()
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/_subprocess.py", line 80, in subprocess_started
target(sockets=sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 65, in run
return asyncio.run(self.serve(sockets=sockets))
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "uvloop/loop.pyx", line 1518, in uvloop.loop.Loop.run_until_complete
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 69, in serve
await self._serve(sockets)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/server.py", line 76, in _serve
config.load()
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/config.py", line 434, in load
self.loaded_app = import_from_string(self.app)
File "/Users/dannier/Desktop/living/AiTool/.venv/lib/python3.9/site-packages/uvicorn/importer.py", line 19, in import_from_string
module = importlib.import_module(module_str)
File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/importlib/__init__.py", line 127, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
File "<frozen importlib._bootstrap>", line 1030, in _gcd_import
File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 850, in exec_module
File "<frozen importlib._bootstrap>", line 228, in _call_with_frames_removed
File "/Users/dannier/Desktop/living/AiTool/backend/app/main.py", line 9, in <module>
from backend.app.routers import customers, projects, finance, settings, ai_settings, email_configs, cloud_docs, portal_links
File "/Users/dannier/Desktop/living/AiTool/backend/app/routers/customers.py", line 7, in <module>
from backend.app import models
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 18, in <module>
class Customer(Base):
File "/Users/dannier/Desktop/living/AiTool/backend/app/models.py", line 23, in Customer
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
INFO: Stopping reloader process [85187]

View File

@@ -6,7 +6,19 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.app.routers import customers, projects, finance
from backend.app.routers import (
customers,
projects,
finance,
settings,
ai_settings,
email_configs,
cloud_docs,
cloud_doc_config,
portal_links,
)
from backend.app.db import Base, engine
from backend.app import models # noqa: F401 - ensure models are imported
def create_app() -> FastAPI:
@@ -16,6 +28,35 @@ def create_app() -> FastAPI:
version="0.1.0",
)
# Ensure database tables exist (especially when running in Docker)
@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)
# Add new columns to finance_records if they don't exist (Module 6)
try:
from sqlalchemy import text
with engine.connect() as conn:
r = conn.execute(text("PRAGMA table_info(finance_records)"))
cols = [row[1] for row in r]
if "amount" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)"))
if "billing_date" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE"))
conn.commit()
except Exception:
pass
# Add customers.tags if missing (customer tags for project 收纳)
try:
from sqlalchemy import text
with engine.connect() as conn:
r = conn.execute(text("PRAGMA table_info(customers)"))
cols = [row[1] for row in r]
if "tags" not in cols:
conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)"))
conn.commit()
except Exception:
pass
# CORS
raw_origins = os.getenv("CORS_ORIGINS")
if raw_origins:
@@ -35,6 +76,12 @@ def create_app() -> FastAPI:
app.include_router(customers.router)
app.include_router(projects.router)
app.include_router(finance.router)
app.include_router(settings.router)
app.include_router(ai_settings.router)
app.include_router(email_configs.router)
app.include_router(cloud_docs.router)
app.include_router(cloud_doc_config.router)
app.include_router(portal_links.router)
# Static data mount (for quotes, contracts, finance archives, etc.)
data_dir = Path("data")

View File

@@ -1,6 +1,7 @@
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import (
Date,
Column,
DateTime,
ForeignKey,
@@ -20,6 +21,7 @@ class Customer(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
tags: Mapped[str | None] = mapped_column(String(512), nullable=True) # 逗号分隔,如:重点客户,已签约
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
@@ -51,6 +53,30 @@ class Project(Base):
quotes: Mapped[list["Quote"]] = relationship(
"Quote", back_populates="project", cascade="all, delete-orphan"
)
cloud_docs: Mapped[list["ProjectCloudDoc"]] = relationship(
"ProjectCloudDoc", back_populates="project", cascade="all, delete-orphan"
)
class ProjectCloudDoc(Base):
"""项目与云文档的映射,用于增量更新(有则 PATCH无则 POST"""
__tablename__ = "project_cloud_docs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
platform: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # feishu | yuque | tencent
cloud_doc_id: Mapped[str] = mapped_column(String(256), nullable=False)
cloud_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
project: Mapped["Project"] = relationship("Project", back_populates="cloud_docs")
class Quote(Base):
@@ -74,9 +100,11 @@ class FinanceRecord(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / ...
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / manual / ...
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)

View File

@@ -0,0 +1,300 @@
"""
AI 模型配置:支持多套配置,持久化在 data/ai_configs.json可选用当前生效配置。
GET /settings/ai 当前选用配置GET /settings/ai/list 列表POST 新增PUT /:id 更新DELETE /:id 删除POST /:id/activate 选用。
"""
import json
import uuid
from pathlib import Path
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel, Field
from backend.app.services.ai_service import get_active_ai_config, test_connection_with_config
router = APIRouter(prefix="/settings/ai", tags=["ai-settings"])
CONFIGS_PATH = Path("data/ai_configs.json")
LEGACY_CONFIG_PATH = Path("data/ai_config.json")
DEFAULT_FIELDS: Dict[str, Any] = {
"provider": "OpenAI",
"api_key": "",
"base_url": "",
"model_name": "gpt-4o-mini",
"temperature": 0.2,
"system_prompt_override": "",
}
class AIConfigRead(BaseModel):
model_config = {"protected_namespaces": ()}
id: str = ""
name: str = ""
provider: str = "OpenAI"
api_key: str = ""
base_url: str = ""
model_name: str = "gpt-4o-mini"
temperature: float = 0.2
system_prompt_override: str = ""
class AIConfigListItem(BaseModel):
"""列表项:不含完整 api_key仅标记是否已配置"""
id: str
name: str
provider: str
model_name: str
base_url: str = ""
api_key_configured: bool = False
is_active: bool = False
class AIConfigCreate(BaseModel):
model_config = {"protected_namespaces": ()}
name: str = Field("", max_length=64)
provider: str = "OpenAI"
api_key: str = ""
base_url: str = ""
model_name: str = "gpt-4o-mini"
temperature: float = 0.2
system_prompt_override: str = ""
class AIConfigUpdate(BaseModel):
model_config = {"protected_namespaces": ()}
name: str | None = Field(None, max_length=64)
provider: str | None = None
api_key: str | None = None
base_url: str | None = None
model_name: str | None = None
temperature: float | None = None
system_prompt_override: str | None = None
def _load_configs_file() -> Dict[str, Any]:
if not CONFIGS_PATH.exists():
return {"configs": [], "active_id": ""}
try:
data = json.loads(CONFIGS_PATH.read_text(encoding="utf-8"))
return {"configs": data.get("configs", []), "active_id": data.get("active_id", "") or ""}
except Exception:
return {"configs": [], "active_id": ""}
def _migrate_from_legacy() -> None:
if CONFIGS_PATH.exists():
return
if not LEGACY_CONFIG_PATH.exists():
return
try:
legacy = json.loads(LEGACY_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return
cfg = {**DEFAULT_FIELDS, **legacy}
new_id = str(uuid.uuid4())[:8]
payload = {
"configs": [
{
"id": new_id,
"name": "默认配置",
"provider": cfg.get("provider", "OpenAI"),
"api_key": cfg.get("api_key", ""),
"base_url": cfg.get("base_url", ""),
"model_name": cfg.get("model_name", "gpt-4o-mini"),
"temperature": cfg.get("temperature", 0.2),
"system_prompt_override": cfg.get("system_prompt_override", ""),
}
],
"active_id": new_id,
}
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIGS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _save_configs(configs: List[Dict], active_id: str) -> None:
CONFIGS_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIGS_PATH.write_text(
json.dumps({"configs": configs, "active_id": active_id}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@router.get("", response_model=AIConfigRead)
async def get_current_ai_settings():
"""返回当前选用的 AI 配置(用于编辑表单与兼容旧接口)。"""
_migrate_from_legacy()
cfg = get_active_ai_config()
return AIConfigRead(
id=cfg.get("id", ""),
name=cfg.get("name", ""),
provider=cfg.get("provider", "OpenAI"),
api_key=cfg.get("api_key", ""),
base_url=cfg.get("base_url", ""),
model_name=cfg.get("model_name", "gpt-4o-mini"),
temperature=float(cfg.get("temperature", 0.2)),
system_prompt_override=cfg.get("system_prompt_override", ""),
)
@router.get("/list", response_model=List[AIConfigListItem])
async def list_ai_configs():
"""列出所有已配置的模型,方便查看、选用或编辑。"""
_migrate_from_legacy()
data = _load_configs_file()
configs = data.get("configs") or []
active_id = data.get("active_id") or ""
out = []
for c in configs:
out.append(
AIConfigListItem(
id=c.get("id", ""),
name=c.get("name", "未命名"),
provider=c.get("provider", "OpenAI"),
model_name=c.get("model_name", ""),
base_url=(c.get("base_url") or "")[:64] or "",
api_key_configured=bool((c.get("api_key") or "").strip()),
is_active=(c.get("id") == active_id),
)
)
return out
@router.get("/{config_id}", response_model=AIConfigRead)
async def get_ai_config_by_id(config_id: str):
"""获取单条配置(用于编辑)。"""
_migrate_from_legacy()
data = _load_configs_file()
for c in data.get("configs") or []:
if c.get("id") == config_id:
return AIConfigRead(
id=c.get("id", ""),
name=c.get("name", ""),
provider=c.get("provider", "OpenAI"),
api_key=c.get("api_key", ""),
base_url=c.get("base_url", ""),
model_name=c.get("model_name", "gpt-4o-mini"),
temperature=float(c.get("temperature", 0.2)),
system_prompt_override=c.get("system_prompt_override", ""),
)
raise HTTPException(status_code=404, detail="配置不存在")
@router.post("", response_model=AIConfigRead, status_code=status.HTTP_201_CREATED)
async def create_ai_config(payload: AIConfigCreate):
"""新增一套模型配置。"""
_migrate_from_legacy()
data = _load_configs_file()
configs = list(data.get("configs") or [])
active_id = data.get("active_id") or ""
new_id = str(uuid.uuid4())[:8]
name = (payload.name or "").strip() or f"{payload.provider} - {payload.model_name}"
new_cfg = {
"id": new_id,
"name": name[:64],
"provider": payload.provider or "OpenAI",
"api_key": payload.api_key or "",
"base_url": (payload.base_url or "").strip(),
"model_name": (payload.model_name or "gpt-4o-mini").strip(),
"temperature": float(payload.temperature) if payload.temperature is not None else 0.2,
"system_prompt_override": (payload.system_prompt_override or "").strip(),
}
configs.append(new_cfg)
if not active_id:
active_id = new_id
_save_configs(configs, active_id)
return AIConfigRead(**new_cfg)
@router.put("/{config_id}", response_model=AIConfigRead)
async def update_ai_config(config_id: str, payload: AIConfigUpdate):
"""更新指定配置。"""
_migrate_from_legacy()
data = _load_configs_file()
configs = data.get("configs") or []
for c in configs:
if c.get("id") == config_id:
if payload.name is not None:
c["name"] = (payload.name or "").strip()[:64] or c.get("name", "")
if payload.provider is not None:
c["provider"] = payload.provider
if payload.api_key is not None:
c["api_key"] = payload.api_key
if payload.base_url is not None:
c["base_url"] = (payload.base_url or "").strip()
if payload.model_name is not None:
c["model_name"] = (payload.model_name or "").strip()
if payload.temperature is not None:
c["temperature"] = float(payload.temperature)
if payload.system_prompt_override is not None:
c["system_prompt_override"] = (payload.system_prompt_override or "").strip()
_save_configs(configs, data.get("active_id", ""))
return AIConfigRead(
id=c.get("id", ""),
name=c.get("name", ""),
provider=c.get("provider", "OpenAI"),
api_key=c.get("api_key", ""),
base_url=c.get("base_url", ""),
model_name=c.get("model_name", "gpt-4o-mini"),
temperature=float(c.get("temperature", 0.2)),
system_prompt_override=c.get("system_prompt_override", ""),
)
raise HTTPException(status_code=404, detail="配置不存在")
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ai_config(config_id: str):
"""删除指定配置;若为当前选用,则改用列表第一项。"""
_migrate_from_legacy()
data = _load_configs_file()
configs = [c for c in (data.get("configs") or []) if c.get("id") != config_id]
active_id = data.get("active_id", "")
if active_id == config_id:
active_id = configs[0].get("id", "") if configs else ""
_save_configs(configs, active_id)
return None
@router.post("/test")
async def test_ai_connection(config_id: str | None = Query(None, description="指定配置 ID不传则用当前选用")):
"""测试连接;不传 config_id 时使用当前选用配置。"""
if config_id:
data = _load_configs_file()
for c in data.get("configs") or []:
if c.get("id") == config_id:
try:
result = await test_connection_with_config(c)
return {"status": "ok", "message": result}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
raise HTTPException(status_code=404, detail="配置不存在")
try:
result = await test_connection_with_config(get_active_ai_config())
return {"status": "ok", "message": result}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@router.post("/{config_id}/activate", response_model=AIConfigRead)
async def activate_ai_config(config_id: str):
"""选用该配置为当前生效。"""
_migrate_from_legacy()
data = _load_configs_file()
exists = any(c.get("id") == config_id for c in (data.get("configs") or []))
if not exists:
raise HTTPException(status_code=404, detail="配置不存在")
_save_configs(data.get("configs", []), config_id)
cfg = get_active_ai_config()
return AIConfigRead(
id=cfg.get("id", ""),
name=cfg.get("name", ""),
provider=cfg.get("provider", "OpenAI"),
api_key=cfg.get("api_key", ""),
base_url=cfg.get("base_url", ""),
model_name=cfg.get("model_name", "gpt-4o-mini"),
temperature=float(cfg.get("temperature", 0.2)),
system_prompt_override=cfg.get("system_prompt_override", ""),
)

View File

@@ -0,0 +1,139 @@
"""
云文档配置:各平台 API 凭证的存储与读取。
飞书 App ID/Secret、语雀 Token、腾讯文档 Client ID/Secret。
"""
import json
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
router = APIRouter(prefix="/settings/cloud-doc-config", tags=["cloud-doc-config"])
CONFIG_PATH = Path("data/cloud_doc_credentials.json")
PLATFORMS = ("feishu", "yuque", "tencent")
class FeishuConfig(BaseModel):
app_id: str = Field("", description="飞书应用 App ID")
app_secret: str = Field("", description="飞书应用 App Secret")
class YuqueConfig(BaseModel):
token: str = Field("", description="语雀 Personal Access Token")
default_repo: str = Field("", description="默认知识库 namespace如 my/repo")
class TencentConfig(BaseModel):
client_id: str = Field("", description="腾讯文档应用 Client ID")
client_secret: str = Field("", description="腾讯文档应用 Client Secret")
class FeishuConfigRead(BaseModel):
app_id: str = ""
app_secret_configured: bool = False
class YuqueConfigRead(BaseModel):
token_configured: bool = False
default_repo: str = ""
class TencentConfigRead(BaseModel):
client_id: str = ""
client_secret_configured: bool = False
class CloudDocConfigRead(BaseModel):
feishu: FeishuConfigRead
yuque: YuqueConfigRead
tencent: TencentConfigRead
def _load_config() -> Dict[str, Any]:
if not CONFIG_PATH.exists():
return {}
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save_config(data: Dict[str, Any]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _mask_secrets_for_read(raw: Dict[str, Any]) -> CloudDocConfigRead:
f = raw.get("feishu") or {}
y = raw.get("yuque") or {}
t = raw.get("tencent") or {}
return CloudDocConfigRead(
feishu=FeishuConfigRead(
app_id=f.get("app_id") or "",
app_secret_configured=bool((f.get("app_secret") or "").strip()),
),
yuque=YuqueConfigRead(
token_configured=bool((y.get("token") or "").strip()),
default_repo=(y.get("default_repo") or "").strip(),
),
tencent=TencentConfigRead(
client_id=t.get("client_id") or "",
client_secret_configured=bool((t.get("client_secret") or "").strip()),
),
)
@router.get("", response_model=CloudDocConfigRead)
async def get_cloud_doc_config():
"""获取云文档配置(凭证以是否已配置返回,不返回明文)。"""
raw = _load_config()
return _mask_secrets_for_read(raw)
@router.put("", response_model=CloudDocConfigRead)
async def update_cloud_doc_config(payload: Dict[str, Any]):
"""
更新云文档配置。传各平台字段,未传的保留原值。
例: { "feishu": { "app_id": "xxx", "app_secret": "yyy" }, "yuque": { "token": "zzz", "default_repo": "a/b" } }
"""
raw = _load_config()
for platform in PLATFORMS:
if platform not in payload or not isinstance(payload[platform], dict):
continue
p = payload[platform]
if platform == "feishu":
if "app_id" in p and p["app_id"] is not None:
raw.setdefault("feishu", {})["app_id"] = str(p["app_id"]).strip()
if "app_secret" in p and p["app_secret"] is not None:
raw.setdefault("feishu", {})["app_secret"] = str(p["app_secret"]).strip()
elif platform == "yuque":
if "token" in p and p["token"] is not None:
raw.setdefault("yuque", {})["token"] = str(p["token"]).strip()
if "default_repo" in p and p["default_repo"] is not None:
raw.setdefault("yuque", {})["default_repo"] = str(p["default_repo"]).strip()
elif platform == "tencent":
if "client_id" in p and p["client_id"] is not None:
raw.setdefault("tencent", {})["client_id"] = str(p["client_id"]).strip()
if "client_secret" in p and p["client_secret"] is not None:
raw.setdefault("tencent", {})["client_secret"] = str(p["client_secret"]).strip()
_save_config(raw)
return _mask_secrets_for_read(raw)
def get_credentials(platform: str) -> Dict[str, str]:
"""供 cloud_doc_service 使用:读取某平台明文凭证。"""
raw = _load_config()
return (raw.get(platform) or {}).copy()
def get_all_credentials() -> Dict[str, Dict[str, str]]:
"""供推送流程使用:读取全部平台凭证(明文)。"""
raw = _load_config()
return {k: dict(v) for k, v in raw.items() if isinstance(v, dict)}

View File

@@ -0,0 +1,91 @@
"""
云文档快捷入口:持久化在 data/cloud_docs.json支持增删改查。
"""
import json
import uuid
from pathlib import Path
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
router = APIRouter(prefix="/settings/cloud-docs", tags=["cloud-docs"])
CONFIG_PATH = Path("data/cloud_docs.json")
class CloudDocLinkCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
url: str = Field(..., min_length=1, max_length=512, description="登录/入口 URL")
class CloudDocLinkRead(BaseModel):
id: str
name: str
url: str
class CloudDocLinkUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=64)
url: str | None = Field(None, min_length=1, max_length=512)
def _load_links() -> List[Dict[str, Any]]:
if not CONFIG_PATH.exists():
return []
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def _save_links(links: List[Dict[str, Any]]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(links, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@router.get("", response_model=List[CloudDocLinkRead])
async def list_cloud_docs():
"""获取所有云文档快捷入口。"""
links = _load_links()
return [CloudDocLinkRead(**x) for x in links]
@router.post("", response_model=CloudDocLinkRead, status_code=status.HTTP_201_CREATED)
async def create_cloud_doc(payload: CloudDocLinkCreate):
"""新增一条云文档入口。"""
links = _load_links()
new_id = str(uuid.uuid4())[:8]
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
links.append(new_item)
_save_links(links)
return CloudDocLinkRead(**new_item)
@router.put("/{link_id}", response_model=CloudDocLinkRead)
async def update_cloud_doc(link_id: str, payload: CloudDocLinkUpdate):
"""更新名称或 URL。"""
links = _load_links()
for item in links:
if item.get("id") == link_id:
if payload.name is not None:
item["name"] = payload.name.strip()
if payload.url is not None:
item["url"] = payload.url.strip()
_save_links(links)
return CloudDocLinkRead(**item)
raise HTTPException(status_code=404, detail="云文档入口不存在")
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_cloud_doc(link_id: str):
"""删除一条云文档入口。"""
links = _load_links()
new_list = [x for x in links if x.get("id") != link_id]
if len(new_list) == len(links):
raise HTTPException(status_code=404, detail="云文档入口不存在")
_save_links(new_list)

View File

@@ -16,9 +16,18 @@ router = APIRouter(prefix="/customers", tags=["customers"])
@router.get("/", response_model=List[CustomerRead])
async def list_customers(db: Session = Depends(get_db)):
customers = db.query(models.Customer).order_by(models.Customer.created_at.desc()).all()
return customers
async def list_customers(
q: str | None = None,
db: Session = Depends(get_db),
):
"""列表客户,支持 q 按名称、联系方式模糊搜索。"""
query = db.query(models.Customer).order_by(models.Customer.created_at.desc())
if q and q.strip():
term = f"%{q.strip()}%"
query = query.filter(
(models.Customer.name.ilike(term)) | (models.Customer.contact_info.ilike(term))
)
return query.all()
@router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
@@ -26,6 +35,7 @@ async def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)
customer = models.Customer(
name=payload.name,
contact_info=payload.contact_info,
tags=payload.tags,
)
db.add(customer)
db.commit()
@@ -53,6 +63,8 @@ async def update_customer(
customer.name = payload.name
if payload.contact_info is not None:
customer.contact_info = payload.contact_info
if payload.tags is not None:
customer.tags = payload.tags
db.commit()
db.refresh(customer)

View File

@@ -0,0 +1,183 @@
"""
Email accounts for multi-email finance sync. Stored in data/email_configs.json.
"""
import json
import uuid
from pathlib import Path
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
router = APIRouter(prefix="/settings/email", tags=["email-configs"])
CONFIG_PATH = Path("data/email_configs.json")
class EmailConfigCreate(BaseModel):
host: str = Field(..., description="IMAP host")
port: int = Field(993, description="IMAP port")
user: str = Field(..., description="Email address")
password: str = Field(..., description="Password or authorization code")
mailbox: str = Field("INBOX", description="Mailbox name")
active: bool = Field(True, description="Include in sync")
class EmailConfigRead(BaseModel):
id: str
host: str
port: int
user: str
mailbox: str
active: bool
class EmailConfigUpdate(BaseModel):
host: str | None = None
port: int | None = None
user: str | None = None
password: str | None = None
mailbox: str | None = None
active: bool | None = None
def _load_configs() -> List[Dict[str, Any]]:
if not CONFIG_PATH.exists():
return []
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def _save_configs(configs: List[Dict[str, Any]]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(configs, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _to_read(c: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": c["id"],
"host": c["host"],
"port": c["port"],
"user": c["user"],
"mailbox": c.get("mailbox", "INBOX"),
"active": c.get("active", True),
}
@router.get("", response_model=List[EmailConfigRead])
async def list_email_configs():
"""List all email account configs (password omitted)."""
configs = _load_configs()
return [_to_read(c) for c in configs]
@router.post("", response_model=EmailConfigRead, status_code=status.HTTP_201_CREATED)
async def create_email_config(payload: EmailConfigCreate):
"""Add a new email account."""
configs = _load_configs()
new_id = str(uuid.uuid4())
configs.append({
"id": new_id,
"host": payload.host,
"port": payload.port,
"user": payload.user,
"password": payload.password,
"mailbox": payload.mailbox,
"active": payload.active,
})
_save_configs(configs)
return _to_read(configs[-1])
@router.put("/{config_id}", response_model=EmailConfigRead)
async def update_email_config(config_id: str, payload: EmailConfigUpdate):
"""Update an email account (omit password to keep existing)."""
configs = _load_configs()
for c in configs:
if c.get("id") == config_id:
if payload.host is not None:
c["host"] = payload.host
if payload.port is not None:
c["port"] = payload.port
if payload.user is not None:
c["user"] = payload.user
if payload.password is not None:
c["password"] = payload.password
if payload.mailbox is not None:
c["mailbox"] = payload.mailbox
if payload.active is not None:
c["active"] = payload.active
_save_configs(configs)
return _to_read(c)
raise HTTPException(status_code=404, detail="Email config not found")
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_email_config(config_id: str):
"""Remove an email account."""
configs = _load_configs()
new_list = [c for c in configs if c.get("id") != config_id]
if len(new_list) == len(configs):
raise HTTPException(status_code=404, detail="Email config not found")
_save_configs(new_list)
@router.get("/{config_id}/folders")
async def list_email_folders(config_id: str):
"""
List mailbox folders for this account (for choosing custom labels).
Returns [{ "raw": "...", "decoded": "收件箱" }, ...]. Use decoded for display and for mailbox config.
"""
import asyncio
from backend.app.services.email_service import list_mailboxes_for_config
configs = _load_configs()
config = next((c for c in configs if c.get("id") == config_id), None)
if not config:
raise HTTPException(status_code=404, detail="Email config not found")
host = config.get("host")
user = config.get("user")
password = config.get("password")
port = int(config.get("port", 993))
if not all([host, user, password]):
raise HTTPException(status_code=400, detail="Config missing host/user/password")
def _fetch():
return list_mailboxes_for_config(host, port, user, password)
try:
folders = await asyncio.to_thread(_fetch)
except Exception as e:
raise HTTPException(status_code=502, detail=f"无法连接邮箱或获取文件夹列表: {e}") from e
return {"folders": [{"raw": r, "decoded": d} for r, d in folders]}
def get_email_configs_for_sync() -> List[Dict[str, Any]]:
"""Return list of configs that are active (for sync). Falls back to env if file empty."""
configs = _load_configs()
active = [c for c in configs if c.get("active", True)]
if active:
return active
# Fallback to single account from env
import os
host = os.getenv("IMAP_HOST")
user = os.getenv("IMAP_USER")
password = os.getenv("IMAP_PASSWORD")
if host and user and password:
return [{
"id": "env",
"host": host,
"port": int(os.getenv("IMAP_PORT", "993")),
"user": user,
"password": password,
"mailbox": os.getenv("IMAP_MAILBOX", "INBOX"),
"active": True,
}]
return []

View File

@@ -1,8 +1,20 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from typing import List
from backend.app.schemas import FinanceSyncResponse, FinanceSyncResult
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app import models
from backend.app.schemas import (
FinanceRecordRead,
FinanceRecordUpdate,
FinanceSyncResponse,
FinanceSyncResult,
FinanceUploadResponse,
)
from backend.app.services.email_service import create_monthly_zip, sync_finance_emails
from backend.app.services.invoice_upload import process_invoice_upload
router = APIRouter(prefix="/finance", tags=["finance"])
@@ -13,10 +25,87 @@ async def sync_finance():
try:
items_raw = await sync_finance_emails()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
raise HTTPException(status_code=400, detail=str(exc)) from exc
items = [FinanceSyncResult(**item) for item in items_raw]
return FinanceSyncResponse(items=items)
details = [FinanceSyncResult(**item) for item in items_raw]
return FinanceSyncResponse(
status="success",
new_files=len(details),
details=details,
)
@router.get("/months", response_model=List[str])
async def list_finance_months(db: Session = Depends(get_db)):
"""List distinct months that have finance records (YYYY-MM), newest first."""
from sqlalchemy import distinct
rows = (
db.query(distinct(models.FinanceRecord.month))
.order_by(models.FinanceRecord.month.desc())
.all()
)
return [r[0] for r in rows]
@router.get("/records", response_model=List[FinanceRecordRead])
async def list_finance_records(
month: str = Query(..., description="YYYY-MM"),
db: Session = Depends(get_db),
):
"""List finance records for a given month."""
records = (
db.query(models.FinanceRecord)
.filter(models.FinanceRecord.month == month)
.order_by(models.FinanceRecord.created_at.desc())
.all()
)
return records
@router.post("/upload", response_model=FinanceUploadResponse, status_code=201)
async def upload_invoice(
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""Upload an invoice (PDF or image). Saves to data/finance/{YYYY-MM}/manual/, runs AI OCR for amount/date."""
suf = (file.filename or "").lower().split(".")[-1] if "." in (file.filename or "") else ""
allowed = {"pdf", "jpg", "jpeg", "png", "webp"}
if suf not in allowed:
raise HTTPException(400, "仅支持 PDF、JPG、PNG、WEBP 格式")
file_name, file_path, month_str, amount, billing_date = await process_invoice_upload(file)
record = models.FinanceRecord(
month=month_str,
type="manual",
file_name=file_name,
file_path=file_path,
amount=amount,
billing_date=billing_date,
)
db.add(record)
db.commit()
db.refresh(record)
return record
@router.patch("/records/{record_id}", response_model=FinanceRecordRead)
async def update_finance_record(
record_id: int,
payload: FinanceRecordUpdate,
db: Session = Depends(get_db),
):
"""Update amount and/or billing_date of a finance record (e.g. after manual review)."""
record = db.query(models.FinanceRecord).get(record_id)
if not record:
raise HTTPException(404, "记录不存在")
if payload.amount is not None:
record.amount = payload.amount
if payload.billing_date is not None:
record.billing_date = payload.billing_date
db.commit()
db.refresh(record)
return record
@router.get("/download/{month}")

View File

@@ -0,0 +1,91 @@
"""
快捷门户入口:持久化在 data/portal_links.json支持增删改查。
"""
import json
import uuid
from pathlib import Path
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
router = APIRouter(prefix="/settings/portal-links", tags=["portal-links"])
CONFIG_PATH = Path("data/portal_links.json")
class PortalLinkCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64, description="显示名称")
url: str = Field(..., min_length=1, max_length=512, description="门户 URL")
class PortalLinkRead(BaseModel):
id: str
name: str
url: str
class PortalLinkUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=64)
url: str | None = Field(None, min_length=1, max_length=512)
def _load_links() -> List[Dict[str, Any]]:
if not CONFIG_PATH.exists():
return []
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
def _save_links(links: List[Dict[str, Any]]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps(links, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@router.get("", response_model=List[PortalLinkRead])
async def list_portal_links():
"""获取所有快捷门户入口。"""
links = _load_links()
return [PortalLinkRead(**x) for x in links]
@router.post("", response_model=PortalLinkRead, status_code=status.HTTP_201_CREATED)
async def create_portal_link(payload: PortalLinkCreate):
"""新增一条快捷门户入口。"""
links = _load_links()
new_id = str(uuid.uuid4())[:8]
new_item = {"id": new_id, "name": payload.name.strip(), "url": payload.url.strip()}
links.append(new_item)
_save_links(links)
return PortalLinkRead(**new_item)
@router.put("/{link_id}", response_model=PortalLinkRead)
async def update_portal_link(link_id: str, payload: PortalLinkUpdate):
"""更新名称或 URL。"""
links = _load_links()
for item in links:
if item.get("id") == link_id:
if payload.name is not None:
item["name"] = payload.name.strip()
if payload.url is not None:
item["url"] = payload.url.strip()
_save_links(links)
return PortalLinkRead(**item)
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_portal_link(link_id: str):
"""删除一条快捷门户入口。"""
links = _load_links()
new_list = [x for x in links if x.get("id") != link_id]
if len(new_list) == len(links):
raise HTTPException(status_code=404, detail="快捷门户入口不存在")
_save_links(new_list)

View File

@@ -1,8 +1,10 @@
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, List, Union
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from backend.app import models
from backend.app.db import get_db
@@ -11,25 +13,35 @@ from backend.app.schemas import (
ContractGenerateResponse,
ProjectRead,
ProjectUpdate,
PushToCloudRequest,
PushToCloudResponse,
QuoteGenerateResponse,
RequirementAnalyzeRequest,
RequirementAnalyzeResponse,
)
from backend.app.services.ai_service import analyze_requirement
from backend.app.services.cloud_doc_service import CloudDocManager
from backend.app.services.doc_service import (
generate_contract_word,
generate_quote_excel,
generate_quote_pdf_from_data,
)
from backend.app.routers.cloud_doc_config import get_all_credentials
router = APIRouter(prefix="/projects", tags=["projects"])
def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str:
"""
Convert structured AI analysis JSON into a human-editable Markdown document.
Tolerates AI returning a list (e.g. modules only) and normalizes to a dict.
"""
if isinstance(data, list):
data = {"modules": data, "total_estimated_hours": None, "total_amount": None, "notes": None}
if not isinstance(data, dict):
data = {}
lines: list[str] = []
lines.append("# 项目方案草稿")
lines.append("")
@@ -48,7 +60,15 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
if modules:
lines.append("## 功能模块与技术实现")
for idx, module in enumerate(modules, start=1):
name = module.get("name", f"模块 {idx}")
if not isinstance(module, dict):
# AI sometimes returns strings or other shapes; treat as a single title line
raw_name = str(module).strip() if module else ""
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
lines.append(f"### {idx}. {name}")
lines.append("")
continue
raw_name = (module.get("name") or "").strip()
name = raw_name if len(raw_name) > 1 and raw_name not in (":", "[", "{", "}") else f"模块 {idx}"
desc = module.get("description") or ""
tech = module.get("technical_approach") or ""
hours = module.get("estimated_hours")
@@ -83,13 +103,31 @@ def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
@router.get("/", response_model=list[ProjectRead])
async def list_projects(db: Session = Depends(get_db)):
projects = (
async def list_projects(
customer_tag: str | None = None,
db: Session = Depends(get_db),
):
"""列表项目customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
query = (
db.query(models.Project)
.options(joinedload(models.Project.customer))
.join(models.Customer)
.order_by(models.Project.created_at.desc())
.all()
)
return projects
if customer_tag and customer_tag.strip():
tag = customer_tag.strip()
# 客户 tags 逗号分隔,按整词匹配
from sqlalchemy import or_
t = models.Customer.tags
query = query.filter(
or_(
t == tag,
t.ilike(f"{tag},%"),
t.ilike(f"%,{tag},%"),
t.ilike(f"%,{tag}"),
)
)
return query.all()
@router.get("/{project_id}", response_model=ProjectRead)
@@ -109,6 +147,8 @@ async def update_project(
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if payload.raw_requirement is not None:
project.raw_requirement = payload.raw_requirement
if payload.ai_solution_md is not None:
project.ai_solution_md = payload.ai_solution_md
if payload.status is not None:
@@ -123,12 +163,24 @@ async def analyze_project_requirement(
payload: RequirementAnalyzeRequest,
db: Session = Depends(get_db),
):
logging.getLogger(__name__).info(
"收到 AI 解析请求: customer_id=%s, 需求长度=%d",
payload.customer_id,
len(payload.raw_text or ""),
)
# Ensure customer exists
customer = db.query(models.Customer).get(payload.customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
analysis = await analyze_requirement(payload.raw_text)
try:
analysis = await analyze_requirement(payload.raw_text)
except RuntimeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
ai_solution_md = _build_markdown_from_analysis(analysis)
project = models.Project(
@@ -151,6 +203,7 @@ async def analyze_project_requirement(
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
async def generate_project_quote(
project_id: int,
template: str | None = None,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
@@ -167,7 +220,9 @@ async def generate_project_quote(
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
template_path = Path("templates/quote_template.xlsx")
from backend.app.routers.settings import get_quote_template_path
template_path = get_quote_template_path(template)
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -254,3 +309,61 @@ async def generate_project_contract(
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))
@router.post("/{project_id}/push-to-cloud", response_model=PushToCloudResponse)
async def push_project_to_cloud(
project_id: int,
payload: PushToCloudRequest,
db: Session = Depends(get_db),
):
"""
将当前项目方案Markdown推送到云文档。若该项目此前已推送过该平台则更新原文档增量同步
"""
project = db.query(models.Project).options(joinedload(models.Project.customer)).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
platform = (payload.platform or "").strip().lower()
if platform not in ("feishu", "yuque", "tencent"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="platform 须为 feishu / yuque / tencent",
)
title = (payload.title or "").strip() or f"项目方案 - 项目#{project_id}"
body_md = (payload.body_md if payload.body_md is not None else project.ai_solution_md) or ""
if not body_md.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="暂无方案内容,请先在编辑器中填写或保存方案后再推送",
)
existing = (
db.query(models.ProjectCloudDoc)
.filter(
models.ProjectCloudDoc.project_id == project_id,
models.ProjectCloudDoc.platform == platform,
)
.first()
)
existing_doc_id = existing.cloud_doc_id if existing else None
credentials = get_all_credentials()
manager = CloudDocManager(credentials)
try:
cloud_doc_id, url = await manager.push_markdown(
platform, title, body_md, existing_doc_id=existing_doc_id
)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
if existing:
existing.cloud_doc_id = cloud_doc_id
existing.cloud_url = url
existing.updated_at = datetime.now(timezone.utc)
else:
record = models.ProjectCloudDoc(
project_id=project_id,
platform=platform,
cloud_doc_id=cloud_doc_id,
cloud_url=url,
)
db.add(record)
db.commit()
return PushToCloudResponse(url=url, cloud_doc_id=cloud_doc_id)

View File

@@ -0,0 +1,93 @@
from pathlib import Path
from typing import List
from fastapi import APIRouter, File, HTTPException, UploadFile, status
router = APIRouter(prefix="/settings", tags=["settings"])
TEMPLATES_DIR = Path("data/templates")
ALLOWED_EXCEL = {".xlsx", ".xltx"}
ALLOWED_WORD = {".docx", ".dotx"}
ALLOWED_EXTENSIONS = ALLOWED_EXCEL | ALLOWED_WORD
# Allowed MIME types when client sends Content-Type (validate if present)
ALLOWED_MIME_TYPES = frozenset({
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # .xlsx
"application/vnd.openxmlformats-officedocument.spreadsheetml.template", # .xltx
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", # .dotx
})
def _ensure_templates_dir() -> Path:
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
return TEMPLATES_DIR
@router.get("/templates", response_model=List[dict])
async def list_templates():
"""List uploaded template files (name, type, size, mtime)."""
_ensure_templates_dir()
out: List[dict] = []
for f in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if not f.is_file():
continue
suf = f.suffix.lower()
if suf not in ALLOWED_EXTENSIONS:
continue
st = f.stat()
out.append({
"name": f.name,
"type": "excel" if suf in ALLOWED_EXCEL else "word",
"size": st.st_size,
"uploaded_at": st.st_mtime,
})
return out
@router.post("/templates/upload", status_code=status.HTTP_201_CREATED)
async def upload_template(file: UploadFile = File(...)):
"""Upload a .xlsx, .xltx, .docx or .dotx template to data/templates/."""
suf = Path(file.filename or "").suffix.lower()
if suf not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only .xlsx, .xltx, .docx and .dotx files are allowed.",
)
content_type = (file.content_type or "").strip().split(";")[0].strip().lower()
if content_type and content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid content type. Allowed: .xlsx, .xltx, .docx, .dotx Office formats.",
)
dir_path = _ensure_templates_dir()
dest = dir_path / (file.filename or "template" + suf)
content = await file.read()
dest.write_bytes(content)
return {"name": dest.name, "path": str(dest)}
def get_latest_excel_template() -> Path | None:
"""Return path to the most recently modified .xlsx or .xltx in data/templates, or None."""
if not TEMPLATES_DIR.exists():
return None
excel_files = [
f for f in TEMPLATES_DIR.iterdir()
if f.is_file() and f.suffix.lower() in ALLOWED_EXCEL
]
if not excel_files:
return None
return max(excel_files, key=lambda p: p.stat().st_mtime)
def get_quote_template_path(template_filename: str | None) -> Path:
"""Resolve quote template path: optional filename in data/templates or latest excel template or default."""
if template_filename:
candidate = TEMPLATES_DIR / template_filename
if candidate.is_file() and candidate.suffix.lower() in ALLOWED_EXCEL:
return candidate
latest = get_latest_excel_template()
if latest:
return latest
default = Path("templates/quote_template.xlsx")
return default

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
class CustomerBase(BaseModel):
name: str = Field(..., description="Customer name")
contact_info: Optional[str] = Field(None, description="Contact information")
tags: Optional[str] = Field(None, description="Comma-separated tags, e.g. 重点客户,已签约")
class CustomerCreate(CustomerBase):
@@ -16,6 +17,7 @@ class CustomerCreate(CustomerBase):
class CustomerUpdate(BaseModel):
name: Optional[str] = None
contact_info: Optional[str] = None
tags: Optional[str] = None
class CustomerRead(CustomerBase):
@@ -33,12 +35,14 @@ class ProjectRead(BaseModel):
ai_solution_md: Optional[str] = None
status: str
created_at: datetime
customer: Optional[CustomerRead] = None
class Config:
from_attributes = True
class ProjectUpdate(BaseModel):
raw_requirement: Optional[str] = None
ai_solution_md: Optional[str] = None
status: Optional[str] = None
@@ -75,6 +79,17 @@ class ContractGenerateResponse(BaseModel):
contract_path: str
class PushToCloudRequest(BaseModel):
platform: str = Field(..., description="feishu | yuque | tencent")
title: Optional[str] = Field(None, description="文档标题,默认使用「项目方案 - 项目#id」")
body_md: Optional[str] = Field(None, description="要推送的 Markdown 内容,不传则使用项目已保存的方案")
class PushToCloudResponse(BaseModel):
url: str
cloud_doc_id: str
class FinanceSyncResult(BaseModel):
id: int
month: str
@@ -84,5 +99,40 @@ class FinanceSyncResult(BaseModel):
class FinanceSyncResponse(BaseModel):
items: List[FinanceSyncResult]
status: str = "success"
new_files: int = 0
details: List[FinanceSyncResult] = Field(default_factory=list)
class FinanceRecordRead(BaseModel):
id: int
month: str
type: str
file_name: str
file_path: str
amount: Optional[float] = None
billing_date: Optional[date] = None
created_at: datetime
class Config:
from_attributes = True
class FinanceRecordUpdate(BaseModel):
amount: Optional[float] = None
billing_date: Optional[date] = None
class FinanceUploadResponse(BaseModel):
id: int
month: str
type: str
file_name: str
file_path: str
amount: Optional[float] = None
billing_date: Optional[date] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -1,37 +1,71 @@
import base64
import json
import os
from typing import Any, Dict
import re
from pathlib import Path
from typing import Any, Dict, Tuple
from openai import AsyncOpenAI
from openai import NotFoundError as OpenAINotFoundError
AI_CONFIG_PATH = Path("data/ai_config.json")
AI_CONFIGS_PATH = Path("data/ai_configs.json")
_client: AsyncOpenAI | None = None
def get_ai_client() -> AsyncOpenAI:
def get_active_ai_config() -> Dict[str, Any]:
"""
Create (or reuse) a singleton AsyncOpenAI client.
The client is configured via:
- AI_API_KEY / OPENAI_API_KEY
- AI_BASE_URL (optional, defaults to official OpenAI endpoint)
- AI_MODEL (optional, defaults to gpt-4.1-mini or a similar capable model)
从 data/ai_configs.json 读取当前选用的配置;若无则从旧版 ai_config.json 迁移并返回。
供 router 与内部调用。
"""
global _client
if _client is not None:
return _client
defaults = {
"id": "",
"name": "",
"provider": "OpenAI",
"api_key": "",
"base_url": "",
"model_name": "gpt-4o-mini",
"temperature": 0.2,
"system_prompt_override": "",
}
if AI_CONFIGS_PATH.exists():
try:
data = json.loads(AI_CONFIGS_PATH.read_text(encoding="utf-8"))
configs = data.get("configs") or []
active_id = data.get("active_id") or ""
for c in configs:
if c.get("id") == active_id:
return {**defaults, **c}
if configs:
return {**defaults, **configs[0]}
except Exception:
pass
# 兼容旧版单文件
if AI_CONFIG_PATH.exists():
try:
data = json.loads(AI_CONFIG_PATH.read_text(encoding="utf-8"))
return {**defaults, **data}
except Exception:
pass
if not defaults.get("api_key"):
defaults["api_key"] = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
if not defaults.get("base_url") and os.getenv("AI_BASE_URL"):
defaults["base_url"] = os.getenv("AI_BASE_URL")
if defaults.get("model_name") == "gpt-4o-mini" and os.getenv("AI_MODEL"):
defaults["model_name"] = os.getenv("AI_MODEL")
return defaults
api_key = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY")
def _load_ai_config() -> Dict[str, Any]:
"""当前生效的 AI 配置(供需求解析、发票识别等使用)。"""
return get_active_ai_config()
def _client_from_config(config: Dict[str, Any]) -> AsyncOpenAI:
api_key = (config.get("api_key") or "").strip()
if not api_key:
raise RuntimeError("AI_API_KEY or OPENAI_API_KEY must be set in environment.")
base_url = os.getenv("AI_BASE_URL") # can point to OpenAI, DeepSeek, Qwen, etc.
_client = AsyncOpenAI(
api_key=api_key,
base_url=base_url or None,
)
return _client
raise RuntimeError("AI API Key 未配置,请在 设置 → AI 模型配置 中填写。")
base_url = (config.get("base_url") or "").strip() or None
return AsyncOpenAI(api_key=api_key, base_url=base_url)
def _build_requirement_prompt(raw_text: str) -> str:
@@ -71,38 +105,139 @@ def _build_requirement_prompt(raw_text: str) -> str:
async def analyze_requirement(raw_text: str) -> Dict[str, Any]:
"""
Call the AI model to analyze customer requirements.
Returns a Python dict matching the JSON structure described
in `_build_requirement_prompt`.
Reads config from data/ai_config.json (and env fallback) on every request.
"""
client = get_ai_client()
model = os.getenv("AI_MODEL", "gpt-4.1-mini")
import logging
logger = logging.getLogger(__name__)
config = _load_ai_config()
client = _client_from_config(config)
model = config.get("model_name") or "gpt-4o-mini"
temperature = float(config.get("temperature", 0.2))
system_override = (config.get("system_prompt_override") or "").strip()
logger.info("AI 需求解析: 调用模型 %s,输入长度 %d 字符", model, len(raw_text))
prompt = _build_requirement_prompt(raw_text)
completion = await client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"你是一名严谨的系统架构师,只能输出有效的 JSON不要输出任何解释文字。"
),
},
{
"role": "user",
"content": prompt,
},
],
temperature=0.2,
system_content = (
system_override
if system_override
else "你是一名严谨的系统架构师,只能输出有效的 JSON不要输出任何解释文字。"
)
try:
completion = await client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": prompt},
],
temperature=temperature,
)
except OpenAINotFoundError as e:
raise RuntimeError(
"当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」与当前提供商一致(如阿里云使用 qwen 系列、OpenAI 使用 gpt-4o-mini 等)。"
) from e
content = completion.choices[0].message.content or "{}"
try:
data: Dict[str, Any] = json.loads(content)
data: Any = json.loads(content)
except json.JSONDecodeError as exc:
logger.error("AI 返回非 JSON片段: %s", (content or "")[:200])
raise RuntimeError(f"AI 返回的内容不是合法 JSON{content}") from exc
# Some models return a list (e.g. modules only); normalize to expected dict shape
if isinstance(data, list):
data = {
"modules": data,
"total_estimated_hours": None,
"total_amount": None,
"notes": None,
}
if not isinstance(data, dict):
data = {}
mods = data.get("modules") or []
logger.info("AI 需求解析完成: 模块数 %d", len(mods) if isinstance(mods, list) else 0)
return data
async def test_connection() -> str:
"""使用当前选用配置测试连接。"""
return await test_connection_with_config(get_active_ai_config())
async def test_connection_with_config(config: Dict[str, Any]) -> str:
"""
使用指定配置发送简单补全以验证 API Key 与 Base URL。
供测试当前配置或指定 config_id 时使用。
"""
client = _client_from_config(config)
model = config.get("model_name") or "gpt-4o-mini"
try:
completion = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Hello"}],
max_tokens=50,
)
except OpenAINotFoundError as e:
raise RuntimeError(
"当前配置的模型不存在或无权访问。请在 设置 → AI 模型配置 中确认「模型名称」(如阿里云使用 qwen 系列)。"
) from e
return (completion.choices[0].message.content or "").strip() or "OK"
async def extract_invoice_metadata(image_bytes: bytes, mime: str = "image/jpeg") -> Tuple[float | None, str | None]:
"""
Use AI vision to extract total amount and invoice date from an image.
Returns (amount, date_yyyy_mm_dd). On any error or unsupported model, returns (None, None).
"""
config = _load_ai_config()
api_key = (config.get("api_key") or "").strip()
if not api_key:
return (None, None)
try:
client = _client_from_config(config)
model = config.get("model_name") or "gpt-4o-mini"
b64 = base64.b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime};base64,{b64}"
prompt = (
"从这张发票/收据图片中识别并提取1) 价税合计/总金额数字不含货币符号2) 开票日期(格式 YYYY-MM-DD"
"只返回 JSON不要其他文字格式{\"amount\": 数字或null, \"date\": \"YYYY-MM-DD\" 或 null}。"
)
completion = await client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
max_tokens=150,
)
content = (completion.choices[0].message.content or "").strip()
if not content:
return (None, None)
# Handle markdown code block
if "```" in content:
content = re.sub(r"^.*?```(?:json)?\s*", "", content).strip()
content = re.sub(r"\s*```.*$", "", content).strip()
data = json.loads(content)
amount_raw = data.get("amount")
date_raw = data.get("date")
amount = None
if amount_raw is not None:
try:
amount = float(amount_raw)
except (TypeError, ValueError):
pass
date_str = None
if isinstance(date_raw, str) and re.match(r"\d{4}-\d{2}-\d{2}", date_raw):
date_str = date_raw[:10]
return (amount, date_str)
except Exception:
return (None, None)

View File

@@ -0,0 +1,315 @@
"""
云文档集成:飞书、语雀、腾讯文档的文档创建/更新。
统一以 Markdown 为中间格式,由各平台 API 写入。
扩展建议:可增加「月度财务明细表」自动导出——每月在飞书/腾讯文档生成表格,
插入当月发票等附件预览链接,供财务查看(需对接财务记录与附件列表)。
"""
from typing import Any, Dict, List, Tuple
import httpx
FEISHU_BASE = "https://open.feishu.cn"
YUQUE_BASE = "https://www.yuque.com/api/v2"
async def get_feishu_tenant_token(app_id: str, app_secret: str) -> str:
"""获取飞书 tenant_access_token。"""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{FEISHU_BASE}/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=10.0,
)
r.raise_for_status()
data = r.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg", "飞书鉴权失败"))
return data["tenant_access_token"]
def _feishu_text_block_elements(md: str) -> List[Dict[str, Any]]:
"""将 Markdown 转为飞书文本块 elements按行拆成 textRun简单实现"""
elements: List[Dict[str, Any]] = []
for line in md.split("\n"):
line = line.rstrip()
if not line:
elements.append({"type": "textRun", "text_run": {"text": "\n"}})
else:
elements.append({"type": "textRun", "text_run": {"text": line + "\n"}})
if not elements:
elements.append({"type": "textRun", "text_run": {"text": " "}})
return elements
async def feishu_create_doc(
token: str, title: str, body_md: str, folder_token: str = ""
) -> Tuple[str, str]:
"""
创建飞书文档并写入内容。返回 (document_id, url)。
使用 docx/v1创建文档后向根块下添加子块写入 Markdown 文本。
"""
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {token}"}
# 1. 创建文档
create_body: Dict[str, Any] = {"title": title[:50] or "未命名文档"}
if folder_token:
create_body["folder_token"] = folder_token
r = await client.post(
f"{FEISHU_BASE}/open-apis/docx/v1/documents",
headers=headers,
json=create_body,
timeout=15.0,
)
r.raise_for_status()
data = r.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg", "飞书创建文档失败"))
doc = data.get("data", {})
document_id = doc.get("document", {}).get("document_id")
if not document_id:
raise RuntimeError("飞书未返回 document_id")
url = doc.get("document", {}).get("url", "")
# 2. 根块 ID 即 document_id飞书约定
block_id = document_id
# 3. 添加子块(内容)
elements = _feishu_text_block_elements(body_md)
# 单块有长度限制,分批写入多块
chunk_size = 3000
for i in range(0, len(elements), chunk_size):
chunk = elements[i : i + chunk_size]
body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1}
r3 = await client.post(
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children",
headers=headers,
json=body_json,
timeout=15.0,
)
r3.raise_for_status()
res = r3.json()
if res.get("code") != 0:
raise RuntimeError(res.get("msg", "飞书写入块失败"))
# 下一批挂在刚创建的块下
new_items = res.get("data", {}).get("children", [])
if new_items:
block_id = new_items[0].get("block_id", block_id)
return document_id, url or f"https://feishu.cn/docx/{document_id}"
async def feishu_update_doc(token: str, document_id: str, body_md: str) -> str:
"""
更新飞书文档内容:获取现有块并批量更新首个文本块,或追加新块。
返回文档 URL。
"""
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {token}"}
r = await client.get(
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks",
headers=headers,
params={"document_id": document_id},
timeout=10.0,
)
r.raise_for_status()
data = r.json()
if data.get("code") != 0:
raise RuntimeError(data.get("msg", "飞书获取块失败"))
items = data.get("data", {}).get("items", [])
elements = _feishu_text_block_elements(body_md)
if items:
first_id = items[0].get("block_id")
if first_id:
# 批量更新:只更新第一个块的内容
update_body = {
"requests": [
{
"request_type": "blockUpdate",
"block_id": first_id,
"update_text": {"elements": elements},
}
]
}
r2 = await client.patch(
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/batch_update",
headers=headers,
json=update_body,
timeout=15.0,
)
r2.raise_for_status()
if r2.json().get("code") != 0:
# 若 PATCH 不支持该块类型,则追加新块
pass
else:
return f"https://feishu.cn/docx/{document_id}"
# 无块或更新失败:在根下追加子块
block_id = document_id
for i in range(0, len(elements), 3000):
chunk = elements[i : i + 3000]
body_json = {"children": [{"block_type": "text", "text": {"elements": chunk}}], "index": -1}
r3 = await client.post(
f"{FEISHU_BASE}/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children",
headers=headers,
json=body_json,
timeout=15.0,
)
r3.raise_for_status()
res = r3.json()
if res.get("data", {}).get("children"):
block_id = res["data"]["children"][0].get("block_id", block_id)
return f"https://feishu.cn/docx/{document_id}"
# --------------- 语雀 ---------------
async def yuque_create_doc(
token: str, repo_id_or_namespace: str, title: str, body_md: str
) -> Tuple[str, str]:
"""
在语雀知识库创建文档。repo_id_or_namespace 可为 repo_id 或 namespace如 user/repo
返回 (doc_id, url)。
"""
async with httpx.AsyncClient() as client:
headers = {
"X-Auth-Token": token,
"Content-Type": "application/json",
"User-Agent": "OpsCore-CloudDoc/1.0",
}
# 若为 namespace 需先解析为 repo_id语雀 API 创建文档用 repo_id
repo_id = repo_id_or_namespace
if "/" in repo_id_or_namespace:
r_repo = await client.get(
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}",
headers=headers,
timeout=10.0,
)
if r_repo.status_code == 200 and r_repo.json().get("data"):
repo_id = str(r_repo.json()["data"]["id"])
r = await client.post(
f"{YUQUE_BASE}/repos/{repo_id}/docs",
headers=headers,
json={
"title": title[:100] or "未命名",
"body": body_md,
"format": "markdown",
},
timeout=15.0,
)
r.raise_for_status()
data = r.json()
doc = data.get("data", {})
doc_id = str(doc.get("id", ""))
url = doc.get("url", "")
if not url and doc.get("slug"):
url = f"https://www.yuque.com/{doc.get('namespace', '').replace('/', '/')}/{doc.get('slug', '')}"
return doc_id, url or ""
async def yuque_update_doc(
token: str, repo_id_or_namespace: str, doc_id: str, title: str, body_md: str
) -> str:
"""更新语雀文档。返回文档 URL。"""
async with httpx.AsyncClient() as client:
headers = {
"X-Auth-Token": token,
"Content-Type": "application/json",
"User-Agent": "OpsCore-CloudDoc/1.0",
}
r = await client.put(
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs/{doc_id}",
headers=headers,
json={
"title": title[:100] or "未命名",
"body": body_md,
"format": "markdown",
},
timeout=15.0,
)
r.raise_for_status()
data = r.json()
doc = data.get("data", {})
return doc.get("url", "") or f"https://www.yuque.com/docs/{doc_id}"
async def yuque_list_docs(token: str, repo_id_or_namespace: str) -> List[Dict[str, Any]]:
"""获取知识库文档列表。"""
async with httpx.AsyncClient() as client:
headers = {
"X-Auth-Token": token,
"User-Agent": "OpsCore-CloudDoc/1.0",
}
r = await client.get(
f"{YUQUE_BASE}/repos/{repo_id_or_namespace}/docs",
headers=headers,
timeout=10.0,
)
r.raise_for_status()
data = r.json()
return data.get("data", [])
# --------------- 腾讯文档(占位) ---------------
async def tencent_create_doc(client_id: str, client_secret: str, title: str, body_md: str) -> Tuple[str, str]:
"""
腾讯文档需 OAuth 用户授权与文件创建 API此处返回占位。
正式接入需在腾讯开放平台创建应用并走 OAuth 流程。
"""
raise RuntimeError(
"腾讯文档 Open API 需在开放平台配置 OAuth 并获取用户授权;当前版本请先用飞书或语雀推送。"
)
# --------------- 统一入口 ---------------
class CloudDocManager:
"""统一封装:读取配置并执行创建/更新,支持增量(有 cloud_doc_id 则更新)。"""
def __init__(self, credentials: Dict[str, Dict[str, str]]):
self.credentials = credentials
async def push_markdown(
self,
platform: str,
title: str,
body_md: str,
existing_doc_id: str | None = None,
extra: Dict[str, str] | None = None,
) -> Tuple[str, str]:
"""
将 Markdown 推送到指定平台。若 existing_doc_id 存在则更新,否则创建。
返回 (cloud_doc_id, url)。
extra: 平台相关参数,如 yuque 的 default_repo。
"""
extra = extra or {}
if platform == "feishu":
cred = self.credentials.get("feishu") or {}
app_id = (cred.get("app_id") or "").strip()
app_secret = (cred.get("app_secret") or "").strip()
if not app_id or not app_secret:
raise RuntimeError("请先在设置中配置飞书 App ID 与 App Secret")
token = await get_feishu_tenant_token(app_id, app_secret)
if existing_doc_id:
url = await feishu_update_doc(token, existing_doc_id, body_md)
return existing_doc_id, url
return await feishu_create_doc(token, title, body_md)
if platform == "yuque":
cred = self.credentials.get("yuque") or {}
token = (cred.get("token") or "").strip()
default_repo = (cred.get("default_repo") or extra.get("repo") or "").strip()
if not token:
raise RuntimeError("请先在设置中配置语雀 Personal Access Token")
if not default_repo:
raise RuntimeError("请先在设置中配置语雀默认知识库namespace如 user/repo")
if existing_doc_id:
url = await yuque_update_doc(token, default_repo, existing_doc_id, title, body_md)
return existing_doc_id, url
return await yuque_create_doc(token, default_repo, title, body_md)
if platform == "tencent":
await tencent_create_doc("", "", title, body_md)
return "", ""
raise RuntimeError(f"不支持的平台: {platform}")

View File

@@ -44,7 +44,12 @@ async def generate_quote_excel(
# Assume the first worksheet is used for the quote.
ws = wb.active
modules: List[Dict[str, Any]] = project_data.get("modules", [])
raw_modules: List[Any] = project_data.get("modules", [])
# Normalize: only dicts have .get(); coerce others to a minimal dict
modules: List[Dict[str, Any]] = [
m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"}
for i, m in enumerate(raw_modules, start=1)
]
total_amount = project_data.get("total_amount")
total_hours = project_data.get("total_estimated_hours")
notes = project_data.get("notes")
@@ -157,7 +162,11 @@ async def generate_quote_pdf_from_data(
c.setFont("Helvetica", 10)
modules: List[Dict[str, Any]] = project_data.get("modules", [])
raw_modules: List[Any] = project_data.get("modules", [])
modules = [
m if isinstance(m, dict) else {"name": str(m) or f"模块 {i}"}
for i, m in enumerate(raw_modules, start=1)
]
for idx, module in enumerate(modules, start=1):
name = module.get("name", "")
hours = module.get("estimated_hours", "")

View File

@@ -1,17 +1,34 @@
import asyncio
import email
import hashlib
import imaplib
import logging
import os
from datetime import datetime
import re
import sqlite3
import ssl
from datetime import date, datetime
from email.header import decode_header
from pathlib import Path
from typing import Any, Dict, List, Tuple
# Ensure IMAP ID command is recognised by imaplib so we can spoof a
# desktop mail client (Foxmail/Outlook) for providers like NetEase/163.
imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED")
from sqlalchemy.orm import Session
from backend.app.db import SessionLocal
from backend.app.models import FinanceRecord
FINANCE_BASE_DIR = Path("data/finance")
SYNC_DB_PATH = Path("data/finance/sync_history.db")
# Folder names for classification (invoices, receipts, statements)
INVOICES_DIR = "invoices"
RECEIPTS_DIR = "receipts"
STATEMENTS_DIR = "statements"
def _decode_header_value(value: str | None) -> str:
@@ -27,17 +44,21 @@ def _decode_header_value(value: str | None) -> str:
return decoded
def _classify_type(subject: str) -> str:
def _classify_type(subject: str, filename: str) -> str:
"""
Classify finance document type based on subject keywords.
Classify finance document type. Returns: invoices, receipts, statements, others.
Maps to folders: invoices/, receipts/, statements/.
"""
subject_lower = subject.lower()
text = f"{subject} {filename}".lower()
# 发票 / 开票类
if any(k in subject for k in ["发票", "开票", "票据", "invoice"]):
if any(k in text for k in ["发票", "开票", "票据", "invoice", "fapiao"]):
return "invoices"
# 回执
if any(k in text for k in ["回执", "签收单", "receipt"]):
return "receipts"
# 银行流水 / 账户明细 / 对公活期等
if any(
k in subject
k in text
for k in [
"流水",
"活期",
@@ -50,9 +71,7 @@ def _classify_type(subject: str) -> str:
"statement",
]
):
return "bank_records"
if any(k in subject for k in ["回执", "receipt"]):
return "receipts"
return "statements"
return "others"
@@ -71,132 +90,474 @@ def _parse_email_date(msg: email.message.Message) -> datetime:
return dt
def _run_invoice_ocr_sync(file_path: str, mime: str, raw_bytes: bytes) -> Tuple[float | None, str | None]:
"""Run extract_invoice_metadata from a sync context (new event loop). Handles PDF via first page image."""
from backend.app.services.ai_service import extract_invoice_metadata
from backend.app.services.invoice_upload import _pdf_first_page_to_image
if "pdf" in (mime or "").lower() or Path(file_path).suffix.lower() == ".pdf":
img_result = _pdf_first_page_to_image(raw_bytes)
if img_result:
image_bytes, img_mime = img_result
raw_bytes, mime = image_bytes, img_mime
# else keep raw_bytes and try anyway (may fail)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(extract_invoice_metadata(raw_bytes, mime))
finally:
loop.close()
def _rename_invoice_file(
file_path: str,
amount: float | None,
billing_date: date | None,
) -> Tuple[str, str]:
"""
Rename invoice file to YYYYMMDD_金额_原文件名.
Returns (new_file_name, new_file_path).
"""
path = Path(file_path)
if not path.exists():
return (path.name, file_path)
date_str = (billing_date or date.today()).strftime("%Y%m%d")
amount_str = f"{amount:.2f}" if amount is not None else "0.00"
# Sanitize original name: take stem, limit length
orig_stem = path.stem[: 80] if len(path.stem) > 80 else path.stem
suffix = path.suffix
new_name = f"{date_str}_{amount_str}_{orig_stem}{suffix}"
new_path = path.parent / new_name
counter = 1
while new_path.exists():
new_path = path.parent / f"{date_str}_{amount_str}_{orig_stem}_{counter}{suffix}"
counter += 1
path.rename(new_path)
return (new_path.name, str(new_path))
def _ensure_sync_history_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS attachment_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
file_hash TEXT NOT NULL,
month TEXT,
doc_type TEXT,
file_name TEXT,
file_path TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, file_hash)
)
"""
)
conn.commit()
def _has_sync_history() -> bool:
"""是否有过同步记录无记录视为首次同步需拉全量有记录则只拉增量UNSEEN"""
if not SYNC_DB_PATH.exists():
return False
try:
conn = sqlite3.connect(SYNC_DB_PATH)
try:
cur = conn.execute("SELECT 1 FROM attachment_history LIMIT 1")
return cur.fetchone() is not None
finally:
conn.close()
except Exception:
return False
def _save_attachment(
msg: email.message.Message,
month_str: str,
doc_type: str,
) -> List[Tuple[str, str]]:
) -> List[Tuple[str, str, str, bytes, str]]:
"""
Save PDF/image attachments and return list of (file_name, file_path).
Save PDF/image attachments.
Returns list of (file_name, file_path, mime, raw_bytes, doc_type).
raw_bytes kept for invoice OCR when doc_type == invoices.
同时使用 data/finance/sync_history.db 做增量去重:
- 以 (message_id, MD5(content)) 为唯一键,避免重复保存相同附件。
"""
saved: List[Tuple[str, str]] = []
base_dir = _ensure_month_dir(month_str, doc_type)
saved: List[Tuple[str, str, str, bytes, str]] = []
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
if "attachment" not in content_disposition:
continue
msg_id = msg.get("Message-ID") or ""
subject = _decode_header_value(msg.get("Subject"))
filename = part.get_filename()
filename = _decode_header_value(filename)
if not filename:
continue
SYNC_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(SYNC_DB_PATH)
try:
_ensure_sync_history_table(conn)
content_type = part.get_content_type()
maintype = part.get_content_maintype()
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
if "attachment" not in content_disposition:
continue
# Accept pdf and common images
if maintype not in ("application", "image"):
continue
filename = part.get_filename()
filename = _decode_header_value(filename)
if not filename:
continue
data = part.get_payload(decode=True)
if not data:
continue
ext = Path(filename).suffix.lower()
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".xlsx"):
continue
file_path = base_dir / filename
# Ensure unique filename
counter = 1
while file_path.exists():
stem = file_path.stem
suffix = file_path.suffix
file_path = base_dir / f"{stem}_{counter}{suffix}"
counter += 1
maintype = part.get_content_maintype()
if maintype not in ("application", "image"):
continue
with open(file_path, "wb") as f:
f.write(data)
data = part.get_payload(decode=True)
if not data:
continue
saved.append((filename, str(file_path)))
# 分类:基于主题 + 文件名
doc_type = _classify_type(subject, filename)
base_dir = _ensure_month_dir(month_str, doc_type)
# 增量去重:根据 (message_id, md5) 判断是否已同步过
file_hash = hashlib.md5(data).hexdigest() # nosec - content hash only
cur = conn.execute(
"SELECT 1 FROM attachment_history WHERE message_id = ? AND file_hash = ?",
(msg_id, file_hash),
)
if cur.fetchone():
continue
mime = part.get_content_type() or "application/octet-stream"
file_path = base_dir / filename
counter = 1
while file_path.exists():
stem, suffix = file_path.stem, file_path.suffix
file_path = base_dir / f"{stem}_{counter}{suffix}"
counter += 1
file_path.write_bytes(data)
conn.execute(
"""
INSERT OR IGNORE INTO attachment_history
(message_id, file_hash, month, doc_type, file_name, file_path)
VALUES (?, ?, ?, ?, ?, ?)
""",
(msg_id, file_hash, month_str, doc_type, file_path.name, str(file_path)),
)
saved.append((file_path.name, str(file_path), mime, data, doc_type))
finally:
conn.commit()
conn.close()
return saved
def _decode_imap_utf7(s: str | bytes) -> str:
"""Decode IMAP4 UTF-7 mailbox name (RFC 3501). Returns decoded string."""
if isinstance(s, bytes):
s = s.decode("ascii", errors="replace")
if "&" not in s:
return s
parts = s.split("&")
out = [parts[0]]
for i in range(1, len(parts)):
chunk = parts[i]
if "-" in chunk:
u, rest = chunk.split("-", 1)
if u == "":
out.append("&")
else:
try:
# IMAP UTF-7: &BASE64- where BASE64 is modified (,+ instead of /,=)
pad = (4 - len(u) % 4) % 4
b = (u + "=" * pad).translate(str.maketrans(",+", "/="))
decoded = __import__("base64").b64decode(b).decode("utf-16-be")
out.append(decoded)
except Exception:
out.append("&" + chunk)
out.append(rest)
else:
out.append("&" + chunk)
return "".join(out)
def _parse_list_response(data: List[bytes]) -> List[Tuple[str, str]]:
"""Parse imap.list() response to [(raw_name, decoded_name), ...]. Format: (flags) \"delim\" \"mailbox\"."""
import shlex
result: List[Tuple[str, str]] = []
for line in data:
if not isinstance(line, bytes):
continue
try:
line_str = line.decode("ascii", errors="replace")
except Exception:
continue
try:
parts = shlex.split(line_str)
except ValueError:
continue
if not parts:
continue
# Mailbox name is the last part (RFC 3501 LIST: (attrs) delim name)
raw = parts[-1]
decoded = _decode_imap_utf7(raw)
result.append((raw, decoded))
return result
def _list_mailboxes(imap: imaplib.IMAP4_SSL) -> List[Tuple[str, str]]:
"""List all mailboxes. Returns [(raw_name, decoded_name), ...]."""
status, data = imap.list()
if status != "OK" or not data:
return []
return _parse_list_response(data)
def list_mailboxes_for_config(host: str, port: int, user: str, password: str) -> List[Tuple[str, str]]:
"""Connect and list all mailboxes (for dropdown). Returns [(raw_name, decoded_name), ...]."""
with imaplib.IMAP4_SSL(host, int(port)) as imap:
imap.login(user, password)
return _list_mailboxes(imap)
def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
"""
Robust mailbox selection with deep discovery scan.
Strategy:
1. LIST all folders, log raw lines for debugging.
2. Look for entry containing '\\Inbox' flag; if found, SELECT that folder.
3. Try standard candidates: user-configured name / INBOX / common UTF-7 收件箱编码.
4. As last resort, attempt SELECT on every listed folder and log which succeed/fail.
"""
logger = logging.getLogger(__name__)
name = (mailbox or "INBOX").strip() or "INBOX"
# 1) Discovery scan: list all folders and log raw entries
try:
status, data = imap.list()
if status != "OK" or not data:
logger.warning("IMAP LIST returned no data or non-OK status: %s", status)
data = []
except Exception as exc:
logger.error("IMAP LIST failed: %s", exc)
data = []
logger.info("IMAP Discovery Scan: listing all folders for mailbox=%s", name)
for raw in data:
logger.info("IMAP FOLDER RAW: %r", raw)
# 2) 优先按 \\Inbox 属性查找“真正的收件箱”
inbox_candidates: list[str] = []
for raw in data:
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw)
if "\\Inbox" not in line:
continue
m = re.search(r'"([^"]+)"\s*$', line)
if not m:
continue
folder_name = m.group(1)
inbox_candidates.append(folder_name)
# 3) 补充常规候选:配置名 / INBOX / 常见 UTF-7 收件箱编码
primary_names = [name, "INBOX"]
utf7_names = ["&XfJT0ZTx-"]
for nm in primary_names + utf7_names:
if nm not in inbox_candidates:
inbox_candidates.append(nm)
logger.info("IMAP Inbox candidate list (ordered): %r", inbox_candidates)
# 4) 依次尝试候选收件箱
for candidate in inbox_candidates:
for readonly in (False, True):
try:
status, _ = imap.select(candidate, readonly=readonly)
logger.info(
"IMAP SELECT candidate=%r readonly=%s -> %s", candidate, readonly, status
)
if status == "OK":
return True
except Exception as exc:
logger.warning(
"IMAP SELECT failed for candidate=%r readonly=%s: %s",
candidate,
readonly,
exc,
)
# 5) 最后手段:尝试 LIST 返回的每一个文件夹
logger.info("IMAP Fallback: trying SELECT on every listed folder...")
for raw in data:
line = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw)
m = re.search(r'"([^"]+)"\s*$', line)
if not m:
continue
folder_name = m.group(1)
for readonly in (False, True):
try:
status, _ = imap.select(folder_name, readonly=readonly)
logger.info(
"IMAP SELECT fallback folder=%r readonly=%s -> %s",
folder_name,
readonly,
status,
)
if status == "OK":
return True
except Exception as exc:
logger.warning(
"IMAP SELECT fallback failed for folder=%r readonly=%s: %s",
folder_name,
readonly,
exc,
)
logger.error("IMAP: unable to SELECT any inbox-like folder for mailbox=%s", name)
return False
def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[str, Any]]) -> None:
host = config.get("host")
user = config.get("user")
password = config.get("password")
port = int(config.get("port", 993))
mailbox = (config.get("mailbox") or "INBOX").strip() or "INBOX"
if not all([host, user, password]):
return
# Use strict TLS context for modern protocols (TLS 1.2+)
tls_context = ssl.create_default_context()
with imaplib.IMAP4_SSL(host, port, ssl_context=tls_context) as imap:
# Enable low-level IMAP debug output to backend logs to help diagnose
# handshake / protocol / mailbox selection issues with specific providers.
imap.debug = 4
imap.login(user, password)
# NetEase / 163 等会对未知客户端静默限制 SELECT这里通过 ID 命令伪装为常见桌面客户端。
try:
logger = logging.getLogger(__name__)
id_str = (
'("name" "Foxmail" '
'"version" "7.2.25.170" '
'"vendor" "Tencent" '
'"os" "Windows" '
'"os-version" "10.0")'
)
logger.info("IMAP sending Foxmail-style ID: %s", id_str)
# Use low-level command so it works across Python versions.
typ, dat = imap._command("ID", id_str) # type: ignore[attr-defined]
logger.info("IMAP ID command result: %s %r", typ, dat)
except Exception as exc:
# ID 失败不应阻断登录,只记录日志,方便后续排查。
logging.getLogger(__name__).warning("IMAP ID command failed: %s", exc)
if not _select_mailbox(imap, mailbox):
raise RuntimeError(
f"无法选择邮箱「{mailbox}」,请检查该账户的 Mailbox 配置(如 163 使用 INBOX"
)
# 首次同步(历史库无记录):拉取全部邮件中的附件,由 attachment_history 去重
# 已有历史:只拉取未读邮件,避免重复拉取
is_first_sync = not _has_sync_history()
search_criterion = "ALL" if is_first_sync else "UNSEEN"
logging.getLogger(__name__).info(
"Finance sync: %s (criterion=%s)",
"全量" if is_first_sync else "增量",
search_criterion,
)
status, data = imap.search(None, search_criterion)
if status != "OK":
return
id_list = data[0].split()
for msg_id in id_list:
status, msg_data = imap.fetch(msg_id, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
dt = _parse_email_date(msg)
month_str = dt.strftime("%Y-%m")
saved = _save_attachment(msg, month_str)
for file_name, file_path, mime, raw_bytes, doc_type in saved:
final_name = file_name
final_path = file_path
amount = None
billing_date = None
if doc_type == "invoices":
amount, date_str = _run_invoice_ocr_sync(file_path, mime, raw_bytes)
if date_str:
try:
billing_date = date.fromisoformat(date_str[:10])
except ValueError:
billing_date = date.today()
else:
billing_date = date.today()
final_name, final_path = _rename_invoice_file(
file_path, amount, billing_date
)
record = FinanceRecord(
month=month_str,
type=doc_type,
file_name=final_name,
file_path=final_path,
amount=amount,
billing_date=billing_date,
)
db.add(record)
db.flush()
results.append({
"id": record.id,
"month": record.month,
"type": record.type,
"file_name": record.file_name,
"file_path": record.file_path,
})
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
async def sync_finance_emails() -> List[Dict[str, Any]]:
"""
Connect to IMAP, fetch unread finance-related emails, download attachments,
save to filesystem and record FinanceRecord entries.
Sync from all active email configs (data/email_configs.json).
Falls back to env vars if no configs. Classifies into invoices/, receipts/, statements/.
Invoices are renamed to YYYYMMDD_金额_原文件名 using OCR.
"""
def _sync() -> List[Dict[str, Any]]:
host = os.getenv("IMAP_HOST")
user = os.getenv("IMAP_USER")
password = os.getenv("IMAP_PASSWORD")
port = int(os.getenv("IMAP_PORT", "993"))
mailbox = os.getenv("IMAP_MAILBOX", "INBOX")
from backend.app.routers.email_configs import get_email_configs_for_sync
if not all([host, user, password]):
raise RuntimeError("IMAP_HOST, IMAP_USER, IMAP_PASSWORD must be set.")
configs = get_email_configs_for_sync()
if not configs:
raise RuntimeError("未配置邮箱。请在 设置 → 邮箱账户 中添加,或配置 IMAP_* 环境变量。")
results: List[Dict[str, Any]] = []
errors: List[str] = []
db = SessionLocal()
try:
for config in configs:
try:
_sync_one_account(config, db, results)
except Exception as e:
# 不让单个账户的异常中断全部同步,记录错误并继续其他账户。
user = config.get("user", "") or config.get("id", "")
errors.append(f"同步账户 {user} 失败: {e}")
db.commit()
finally:
db.close()
with imaplib.IMAP4_SSL(host, port) as imap:
imap.login(user, password)
imap.select(mailbox)
# Search for UNSEEN emails with finance related keywords in subject.
# Note: IMAP SEARCH is limited; here we search UNSEEN first then filter in Python.
status, data = imap.search(None, "UNSEEN")
if status != "OK":
return results
id_list = data[0].split()
db = SessionLocal()
try:
for msg_id in id_list:
status, msg_data = imap.fetch(msg_id, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = _decode_header_value(msg.get("Subject"))
doc_type = _classify_type(subject)
# Filter by keywords first
if doc_type == "others":
continue
dt = _parse_email_date(msg)
month_str = dt.strftime("%Y-%m")
saved_files = _save_attachment(msg, month_str, doc_type)
for file_name, file_path in saved_files:
record = FinanceRecord(
month=month_str,
type=doc_type,
file_name=file_name,
file_path=file_path,
)
# NOTE: created_at defaults at DB layer
db.add(record)
db.flush()
results.append(
{
"id": record.id,
"month": record.month,
"type": record.type,
"file_name": record.file_name,
"file_path": record.file_path,
}
)
# Mark email as seen and flagged to avoid re-processing
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
db.commit()
finally:
db.close()
if not results and errors:
# 所有账户都失败了,整体报错,前端可显示详细原因。
raise RuntimeError("; ".join(errors))
return results
@@ -205,7 +566,8 @@ async def sync_finance_emails() -> List[Dict[str, Any]]:
async def create_monthly_zip(month_str: str) -> str:
"""
Zip the finance folder for a given month (YYYY-MM) and return the zip path.
Zip the finance folder for a given month (YYYY-MM).
Preserves folder structure (invoices/, receipts/, statements/, manual/) inside the zip.
"""
import zipfile
@@ -227,4 +589,3 @@ async def create_monthly_zip(month_str: str) -> str:
return str(zip_path)
return await asyncio.to_thread(_zip)

View File

@@ -0,0 +1,90 @@
"""
Manual invoice upload: save file, optionally run AI vision to extract amount/date.
"""
import io
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, Tuple
from fastapi import UploadFile
from backend.app.services.ai_service import extract_invoice_metadata
FINANCE_BASE = Path("data/finance")
ALLOWED_IMAGE = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_PDF = {".pdf"}
def _current_month() -> str:
return datetime.utcnow().strftime("%Y-%m")
def _pdf_first_page_to_image(pdf_bytes: bytes) -> Tuple[bytes, str] | None:
"""Render first page of PDF to PNG bytes. Returns (bytes, 'image/png') or None on error."""
try:
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
if doc.page_count == 0:
doc.close()
return None
page = doc[0]
pix = page.get_pixmap(dpi=150)
png_bytes = pix.tobytes("png")
doc.close()
return (png_bytes, "image/png")
except Exception:
return None
async def process_invoice_upload(
file: UploadFile,
) -> Tuple[str, str, str, float | None, date | None]:
"""
Save uploaded file to data/finance/{YYYY-MM}/manual/, run OCR for amount/date.
Returns (file_name, file_path, month_str, amount, billing_date).
"""
month_str = _current_month()
manual_dir = FINANCE_BASE / month_str / "manual"
manual_dir.mkdir(parents=True, exist_ok=True)
raw = await file.read()
filename = file.filename or "upload"
suf = Path(filename).suffix.lower()
if suf in ALLOWED_IMAGE:
image_bytes, mime = raw, (file.content_type or "image/jpeg")
if "png" in (suf or ""):
mime = "image/png"
amount, date_str = await extract_invoice_metadata(image_bytes, mime)
elif suf in ALLOWED_PDF:
image_result = _pdf_first_page_to_image(raw)
if image_result:
image_bytes, mime = image_result
amount, date_str = await extract_invoice_metadata(image_bytes, mime)
else:
amount, date_str = None, None
# Save original PDF
else:
amount, date_str = None, None
# Unique filename
dest = manual_dir / filename
counter = 1
while dest.exists():
dest = manual_dir / f"{dest.stem}_{counter}{dest.suffix}"
counter += 1
dest.write_bytes(raw)
file_path = str(dest)
file_name = dest.name
billing_date = None
if date_str:
try:
billing_date = date.fromisoformat(date_str)
except ValueError:
pass
if billing_date is None:
billing_date = date.today()
return (file_name, file_path, month_str, amount, billing_date)

42
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,42 @@
# 开发模式:代码挂载 + 内部热重载,无需重建镜像即可生效(类似 K8s 仅更新配置/代码并滚动重启)
# 使用方式: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# 或: ./docker_dev.sh dev
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: ops-core-backend
env_file:
- .env
volumes:
- ./data:/app/data
# 挂载代码:宿主机修改后由 uvicorn --reload 自动重启进程,无需重建容器
- ./backend:/app/backend
ports:
- "8000:8000"
# 仅重启进程,不重启容器;代码变更由 reload 自动加载
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: ops-core-frontend
volumes:
- ./frontend:/app
# 保留容器内 node_modules避免被宿主机目录覆盖
- frontend_node_modules:/app/node_modules
ports:
- "3000:3000"
environment:
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: 1
# 开发模式先装依赖volume 首次为空),再 dev代码变更热更新
command: sh -c "npm install && npm run dev"
depends_on:
- backend
volumes:
frontend_node_modules:

View File

@@ -1,3 +1,6 @@
# 生产/默认:分层构建,仅代码变更时只重建最后一层。
# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# 或执行: ./docker_dev.sh dev
services:
backend:
build:

View File

@@ -1,13 +1,55 @@
#!/usr/bin/env bash
# 使用 Docker Compose 一键构建并启动 FastAPI + Next.js
# Docker 开发与部署:支持仅更新变动内容、动态加载,避免每次全量重建
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
echo "[Ops-Core] 使用 Docker 构建并启动服务 (backend + frontend)..."
docker compose -f docker-compose.yml up --build
COMPOSE_BASE="docker compose -f docker-compose.yml"
COMPOSE_DEV="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
# 如需后台模式,可改为:
# docker compose -f docker-compose.yml up --build -d
usage() {
echo "用法: $0 [mode]"
echo ""
echo " (无参数) 默认构建并启动(生产模式,分层构建仅重建变更层)"
echo " dev 开发模式:挂载代码 + 热重载前台Ctrl+C 会停容器)"
echo " dev-bg 开发模式后台运行Ctrl+C 不会停容器,停止用: $0 down"
echo " restart 仅重启容器内服务,不重建镜像"
echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)"
echo " down 停止并移除容器"
exit 0
}
case "${1:-up}" in
up|"")
echo "[Ops-Core] 构建并启动(生产模式)..."
${COMPOSE_BASE} up --build
;;
dev)
echo "[Ops-Core] 开发模式:代码挂载 + 热重载无需重建Ctrl+C 会停止容器)..."
${COMPOSE_DEV} up --build
;;
dev-bg)
echo "[Ops-Core] 开发模式(后台):同上,但 Ctrl+C 不会停容器,需用 ./docker_dev.sh down 停止..."
${COMPOSE_DEV} up --build -d
;;
restart)
echo "[Ops-Core] 仅重启服务(不重建镜像)..."
${COMPOSE_BASE} restart backend frontend
;;
build)
echo "[Ops-Core] 仅构建镜像(变更少时只重建代码层)..."
${COMPOSE_BASE} build
;;
down)
${COMPOSE_BASE} down
${COMPOSE_DEV} down 2>/dev/null || true
;;
-h|--help)
usage
;;
*)
echo "未知参数: $1"
usage
;;
esac

4
frontend/.npmrc Normal file
View File

@@ -0,0 +1,4 @@
# 国内镜像加快安装、避免卡死Docker 内已单独配置,此文件供本地/CI 使用)
registry=https://registry.npmmirror.com
fetch-retries=5
fetch-timeout=60000

View File

@@ -1,10 +1,19 @@
# 分层构建:依赖与代码分离,仅代码变更时只重建 COPY 及以后层
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
# 使用国内镜像源,加快安装并减少网络卡死
RUN npm config set registry https://registry.npmmirror.com \
&& npm config set fetch-retries 5 \
&& npm config set fetch-timeout 60000 \
&& npm config set fetch-retry-mintimeout 10000
# 依赖层:只有 package.json / package-lock 变更时才重建
COPY package.json ./
RUN npm install --prefer-offline --no-audit --progress=false
# 代码层:业务代码变更只重建此层及后续 build
COPY . .
ENV NODE_ENV=production

View File

@@ -0,0 +1,503 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
apiBase,
financeApi,
type FinanceRecordRead,
type FinanceSyncResponse,
type FinanceSyncResult,
} from "@/lib/api/client";
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
import { toast } from "sonner";
export default function FinancePage() {
const [months, setMonths] = useState<string[]>([]);
const [selectedMonth, setSelectedMonth] = useState<string>("");
const [records, setRecords] = useState<FinanceRecordRead[]>([]);
const [loadingMonths, setLoadingMonths] = useState(true);
const [loadingRecords, setLoadingRecords] = useState(false);
const [syncing, setSyncing] = useState(false);
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [reviewRecord, setReviewRecord] = useState<FinanceRecordRead | null>(null);
const [reviewAmount, setReviewAmount] = useState("");
const [reviewDate, setReviewDate] = useState("");
const [savingReview, setSavingReview] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
const loadMonths = useCallback(async () => {
try {
const list = await financeApi.listMonths();
setMonths(list);
if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]);
} catch {
toast.error("加载月份列表失败");
} finally {
setLoadingMonths(false);
}
}, [selectedMonth]);
const loadRecords = useCallback(async () => {
if (!selectedMonth) {
setRecords([]);
return;
}
setLoadingRecords(true);
try {
const list = await financeApi.listRecords(selectedMonth);
setRecords(list);
} catch {
toast.error("加载记录失败");
} finally {
setLoadingRecords(false);
}
}, [selectedMonth]);
useEffect(() => {
loadMonths();
}, [loadMonths]);
useEffect(() => {
loadRecords();
}, [loadRecords]);
const handleSync = async () => {
setSyncing(true);
toast.loading("正在同步邮箱…", { id: "finance-sync" });
try {
const res: FinanceSyncResponse = await financeApi.sync();
setLastSync(res);
toast.dismiss("finance-sync");
if (res.new_files > 0) {
toast.success(`发现 ${res.new_files} 个新文件`);
await loadMonths();
if (selectedMonth) await loadRecords();
} else {
toast.info("收件箱已是最新,无新文件");
}
} catch (e) {
toast.dismiss("finance-sync");
toast.error(e instanceof Error ? e.message : "同步失败");
} finally {
setSyncing(false);
}
};
const resetUploadDialog = useCallback(() => {
setUploadFile(null);
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewUrl(null);
setReviewRecord(null);
setReviewAmount("");
setReviewDate("");
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) {
toast.error("仅支持 PDF、JPG、PNG、WEBP");
return;
}
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setUploadFile(file);
setReviewRecord(null);
setReviewAmount("");
setReviewDate("");
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
previewUrlRef.current = url;
setPreviewUrl(url);
} else {
setPreviewUrl(null);
}
};
const handleUploadSubmit = async () => {
if (!uploadFile) return;
setUploading(true);
try {
const record = await financeApi.uploadInvoice(uploadFile);
setReviewRecord(record);
setReviewAmount(record.amount != null ? String(record.amount) : "");
setReviewDate(record.billing_date || "");
toast.success("已上传,请核对金额与日期");
await loadMonths();
if (selectedMonth === record.month) await loadRecords();
} catch (e) {
toast.error(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const handleReviewSave = async () => {
if (!reviewRecord) return;
const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null;
const billing_date = reviewDate.trim() || null;
setSavingReview(true);
try {
await financeApi.updateRecord(reviewRecord.id, { amount, billing_date });
toast.success("已保存");
setUploadDialogOpen(false);
resetUploadDialog();
if (selectedMonth) await loadRecords();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingReview(false);
}
};
const handleDownloadZip = async () => {
if (!selectedMonth) {
toast.error("请先选择月份");
return;
}
try {
await financeApi.downloadMonth(selectedMonth);
toast.success(`已下载 ${selectedMonth}.zip`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "下载失败");
}
};
const formatDate = (s: string) =>
new Date(s).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
const typeLabel: Record<string, string> = {
invoices: "发票",
bank_records: "流水",
statements: "流水",
receipts: "回执",
manual: "手动上传",
others: "其他",
};
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
const totalInvoicesThisMonth = records.filter(
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
).reduce((s, r) => s + (r.amount ?? 0), 0);
return (
<div className="p-6 max-w-5xl">
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground mt-0.5">
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{selectedMonth && (
<Card className="py-2 px-4">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-semibold">
¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</p>
</Card>
)}
<Button
variant="outline"
onClick={() => {
setUploadDialogOpen(true);
resetUploadDialog();
}}
>
<Upload className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
<Button onClick={handleSync} disabled={syncing} size="default">
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
<span className="ml-2"></span>
</Button>
</div>
</div>
{/* Upload Invoice Dialog */}
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
setUploadDialogOpen(open);
if (!open) resetUploadDialog();
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
</DialogHeader>
{!reviewRecord ? (
<>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50"
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
/>
{previewUrl ? (
<img src={previewUrl} alt="预览" className="max-h-48 mx-auto object-contain" />
) : uploadFile ? (
<p className="text-sm font-medium">{uploadFile.name}</p>
) : (
<p className="text-sm text-muted-foreground"> PDF/</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
</Button>
<Button onClick={handleUploadSubmit} disabled={!uploadFile || uploading}>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span className="ml-2"></span>
</Button>
</DialogFooter>
</>
) : (
<>
{previewUrl && (
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
)}
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
step="0.01"
value={reviewAmount}
onChange={(e) => setReviewAmount(e.target.value)}
placeholder="可手动修改"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
</Button>
<Button onClick={handleReviewSave} disabled={savingReview}>
{savingReview ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span className="ml-2"></span>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* Sync History / Last sync */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Inbox className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="py-2">
{lastSync !== null ? (
<>
<p className="text-sm text-muted-foreground">
<strong>{lastSync.new_files}</strong>
</p>
{lastSync.details && lastSync.details.length > 0 && (
<div className="mt-2 space-y-2">
{Object.entries(
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
(acc, item) => {
const t = item.type || "others";
if (!acc[t]) acc[t] = [];
acc[t].push(item);
return acc;
},
{},
),
).map(([t, items]) => (
<div key={t}>
<p className="text-xs font-medium text-muted-foreground">
{typeLabel[t] ?? t}{items.length}
</p>
<ul className="mt-1 ml-4 list-disc space-y-0.5 text-xs text-muted-foreground">
{items.map((it) => (
<li key={it.id}>
{it.file_name}
<span className="ml-1 text-[11px] text-muted-foreground/80">
[{it.month}]
</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</CardContent>
</Card>
{/* Month + Download */}
<Card>
<CardHeader className="py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-2">
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={loadingMonths}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="选择月份" />
</SelectTrigger>
<SelectContent>
{months.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={handleDownloadZip}
disabled={!selectedMonth || records.length === 0}
>
<Download className="h-4 w-4" />
<span className="ml-1.5"> (.zip)</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{loadingRecords ? (
<p className="text-sm text-muted-foreground flex items-center gap-1 py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : records.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
</p>
) : (
<>
<p className="text-xs text-muted-foreground mb-2">
/ / _金额_原文件名
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<span className="text-muted-foreground">
{typeLabel[r.type] ?? r.type}
</span>
</TableCell>
<TableCell className="font-medium">{r.file_name}</TableCell>
<TableCell>
{r.amount != null
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
: "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.billing_date || formatDate(r.created_at)}
</TableCell>
<TableCell>
<a
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
>
<FileText className="h-3.5 w-3.5" />
</a>
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={2}></TableCell>
<TableCell>
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
</Table>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,391 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
aiSettingsApi,
type AIConfig,
type AIConfigListItem,
type AIConfigCreate,
type AIConfigUpdate,
} from "@/lib/api/client";
import { Loader2, Zap, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
import { toast } from "sonner";
const PROVIDERS = ["OpenAI", "DeepSeek", "Custom"];
export default function SettingsAIPage() {
const [list, setList] = useState<AIConfigListItem[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [formName, setFormName] = useState("");
const [provider, setProvider] = useState("OpenAI");
const [apiKey, setApiKey] = useState("");
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
const [baseUrl, setBaseUrl] = useState("");
const [modelName, setModelName] = useState("gpt-4o-mini");
const [temperature, setTemperature] = useState("0.2");
const [systemPromptOverride, setSystemPromptOverride] = useState("");
const loadList = useCallback(async () => {
try {
const data = await aiSettingsApi.list();
setList(data);
} catch {
toast.error("加载模型列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadList();
}, [loadList]);
const openAdd = () => {
setEditingId(null);
setFormName("");
setProvider("OpenAI");
setApiKey("");
setApiKeyConfigured(false);
setBaseUrl("");
setModelName("gpt-4o-mini");
setTemperature("0.2");
setSystemPromptOverride("");
setDialogOpen(true);
};
const openEdit = async (id: string) => {
try {
const c = await aiSettingsApi.getById(id);
setEditingId(id);
setFormName(c.name || "");
setProvider(c.provider || "OpenAI");
setApiKey("");
setApiKeyConfigured(!!(c.api_key && c.api_key.length > 0));
setBaseUrl(c.base_url || "");
setModelName(c.model_name || "gpt-4o-mini");
setTemperature(String(c.temperature ?? 0.2));
setSystemPromptOverride(c.system_prompt_override || "");
setDialogOpen(true);
} catch {
toast.error("加载配置失败");
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
if (editingId) {
const payload: AIConfigUpdate = {
name: formName.trim() || undefined,
provider: provider || undefined,
base_url: baseUrl.trim() || undefined,
model_name: modelName.trim() || undefined,
temperature: parseFloat(temperature),
system_prompt_override: systemPromptOverride.trim() || undefined,
};
if (apiKey.trim()) payload.api_key = apiKey.trim();
await aiSettingsApi.update(editingId, payload);
toast.success("已更新");
} else {
const payload: AIConfigCreate = {
name: formName.trim() || undefined,
provider: provider || undefined,
api_key: apiKey.trim() || undefined,
base_url: baseUrl.trim() || undefined,
model_name: modelName.trim() || undefined,
temperature: parseFloat(temperature),
system_prompt_override: systemPromptOverride.trim() || undefined,
};
await aiSettingsApi.create(payload);
toast.success("已添加");
}
setDialogOpen(false);
await loadList();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleActivate = async (id: string) => {
try {
await aiSettingsApi.activate(id);
toast.success("已选用该模型");
await loadList();
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
} catch (e) {
toast.error(e instanceof Error ? e.message : "选用失败");
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定删除该模型配置?")) return;
try {
await aiSettingsApi.delete(id);
toast.success("已删除");
await loadList();
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
const handleTest = async (id: string) => {
setTesting(id);
try {
const res = await aiSettingsApi.test(id);
toast.success(res.message ? `连接成功:${res.message}` : "连接成功");
} catch (e) {
toast.error(e instanceof Error ? e.message : "连接失败");
} finally {
setTesting(null);
}
};
if (loading) {
return (
<div className="p-6 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
return (
<div className="p-6 max-w-4xl">
<div className="mb-4">
<Link href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
</Link>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle></CardTitle>
<p className="text-sm text-muted-foreground mt-1">
AI 使
</p>
</div>
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
</div>
</CardHeader>
<CardContent>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
API Key
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.name || "未命名"}
{item.is_active && (
<span className="ml-2 text-xs text-primary flex items-center gap-0.5">
<CheckCircle className="h-3.5 w-3.5" />
</span>
)}
</TableCell>
<TableCell>{item.provider}</TableCell>
<TableCell>{item.model_name || "—"}</TableCell>
<TableCell>{item.api_key_configured ? "已配置" : "未配置"}</TableCell>
<TableCell>
<div className="flex items-center gap-1 flex-wrap">
{!item.is_active && (
<Button
variant="outline"
size="sm"
onClick={() => handleActivate(item.id)}
>
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item.id)}
title="编辑"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleTest(item.id)}
disabled={testing === item.id}
title="测试连接"
>
{testing === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="删除"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型配置" : "添加模型配置"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="ai-name">便</Label>
<Input
id="ai-name"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="如OpenAI 生产、DeepSeek 备用"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider"></Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger id="provider">
<SelectValue placeholder="选择提供商" />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="api_key">API Key</Label>
<Input
id="api_key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"}
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="base_url">Base URL</Label>
<Input
id="base_url"
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="model_name"></Label>
<Input
id="model_name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="gpt-4o-mini / deepseek-chat 等"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="temperature">Temperature (02)</Label>
<Input
id="temperature"
type="number"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="system_prompt_override"></Label>
<textarea
id="system_prompt_override"
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={systemPromptOverride}
onChange={(e) => setSystemPromptOverride(e.target.value)}
placeholder="留空则使用默认"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
{editingId ? "保存" : "添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
cloudDocConfigApi,
type CloudDocConfigRead,
type CloudDocConfigUpdate,
} from "@/lib/api/client";
import { Loader2, Save, FileStack } from "lucide-react";
import { toast } from "sonner";
export default function SettingsCloudDocConfigPage() {
const [config, setConfig] = useState<CloudDocConfigRead | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<CloudDocConfigUpdate>({
feishu: { app_id: "", app_secret: "" },
yuque: { token: "", default_repo: "" },
tencent: { client_id: "", client_secret: "" },
});
const load = useCallback(async () => {
setLoading(true);
try {
const data = await cloudDocConfigApi.get();
setConfig(data);
setForm({
feishu: { app_id: data.feishu.app_id, app_secret: "" },
yuque: { token: "", default_repo: data.yuque.default_repo },
tencent: { client_id: data.tencent.client_id, client_secret: "" },
});
} catch {
toast.error("加载云文档配置失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleSave = async () => {
setSaving(true);
try {
const payload: CloudDocConfigUpdate = {};
if (form.feishu?.app_id !== undefined) payload.feishu = { app_id: form.feishu.app_id };
if (form.feishu?.app_secret !== undefined && form.feishu.app_secret !== "")
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
if (form.yuque?.token !== undefined && form.yuque.token !== "")
payload.yuque = { token: form.yuque.token };
if (form.yuque?.default_repo !== undefined)
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
if (form.tencent?.client_secret !== undefined && form.tencent.client_secret !== "")
payload.tencent = { ...payload.tencent, client_secret: form.tencent.client_secret };
await cloudDocConfigApi.update(payload);
toast.success("已保存");
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="p-6 max-w-2xl flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm text-muted-foreground"></span>
</div>
);
}
return (
<div className="p-6 max-w-2xl">
<div className="mb-4">
<Link
href="/settings"
className="text-sm text-muted-foreground hover:text-foreground"
>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileStack className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
API /线
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* 飞书 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Feishu)</h3>
<div className="grid gap-2">
<Label>App ID</Label>
<Input
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
feishu: { ...f.feishu, app_id: e.target.value },
}))
}
placeholder="在飞书开放平台创建应用后获取"
/>
</div>
<div className="grid gap-2">
<Label>App Secret</Label>
<Input
type="password"
value={form.feishu?.app_secret ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
feishu: { ...f.feishu, app_secret: e.target.value },
}))
}
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
/>
</div>
</div>
{/* 语雀 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Yuque)</h3>
<div className="grid gap-2">
<Label>Personal Access Token</Label>
<Input
type="password"
value={form.yuque?.token ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
yuque: { ...f.yuque, token: e.target.value },
}))
}
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
/>
</div>
<div className="grid gap-2">
<Label> (namespace)</Label>
<Input
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
yuque: { ...f.yuque, default_repo: e.target.value },
}))
}
placeholder="如your_username/repo"
/>
</div>
</div>
{/* 腾讯文档 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Tencent)</h3>
<p className="text-xs text-muted-foreground">
OAuth
</p>
<div className="grid gap-2">
<Label>Client ID</Label>
<Input
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
tencent: { ...f.tencent, client_id: e.target.value },
}))
}
placeholder="开放平台应用 Client ID"
/>
</div>
<div className="grid gap-2">
<Label>Client Secret</Label>
<Input
type="password"
value={form.tencent?.client_secret ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
tencent: { ...f.tencent, client_secret: e.target.value },
}))
}
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
/>
</div>
</div>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Save className="h-4 w-4 mr-2" />}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
cloudDocsApi,
type CloudDocLinkRead,
type CloudDocLinkCreate,
} from "@/lib/api/client";
import { FileStack, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
import { toast } from "sonner";
export default function SettingsCloudDocsPage() {
const [links, setLinks] = useState<CloudDocLinkRead[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: "", url: "" });
const loadLinks = useCallback(async () => {
try {
const list = await cloudDocsApi.list();
setLinks(list);
} catch {
toast.error("加载云文档列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLinks();
}, [loadLinks]);
const openAdd = () => {
setEditingId(null);
setForm({ name: "", url: "" });
setDialogOpen(true);
};
const openEdit = (item: CloudDocLinkRead) => {
setEditingId(item.id);
setForm({ name: item.name, url: item.url });
setDialogOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) {
toast.error("请填写名称和链接");
return;
}
setSaving(true);
try {
if (editingId) {
await cloudDocsApi.update(editingId, {
name: form.name.trim(),
url: form.url.trim(),
});
toast.success("已更新");
} else {
const payload: CloudDocLinkCreate = {
name: form.name.trim(),
url: form.url.trim(),
};
await cloudDocsApi.create(payload);
toast.success("已添加");
}
setDialogOpen(false);
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定删除该云文档入口?")) return;
try {
await cloudDocsApi.delete(id);
toast.success("已删除");
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
return (
<div className="p-6 max-w-4xl">
<div className="mb-4">
<Link
href="/settings"
className="text-sm text-muted-foreground hover:text-foreground"
>
</Link>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<FileStack className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
/
</p>
</div>
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : links.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{links.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline truncate max-w-[280px] inline-block"
>
{item.url}
</a>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item)}
title="编辑"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="删除"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button
variant="ghost"
size="sm"
asChild
title="打开"
>
<a href={item.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "编辑云文档入口" : "添加云文档入口"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="cloud-doc-name"></Label>
<Input
id="cloud-doc-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="如:腾讯文档、飞书、语雀"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cloud-doc-url">/</Label>
<Input
id="cloud-doc-url"
type="url"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://..."
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
{editingId ? "保存" : "添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,364 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
emailConfigsApi,
type EmailConfigRead,
type EmailConfigCreate,
type EmailConfigUpdate,
type EmailFolder,
} from "@/lib/api/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
import { toast } from "sonner";
export default function SettingsEmailPage() {
const [configs, setConfigs] = useState<EmailConfigRead[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
host: "",
port: "993",
user: "",
password: "",
mailbox: "INBOX",
active: true,
});
const [folders, setFolders] = useState<EmailFolder[] | null>(null);
const [foldersLoading, setFoldersLoading] = useState(false);
const loadConfigs = useCallback(async () => {
try {
const list = await emailConfigsApi.list();
setConfigs(list);
} catch {
toast.error("加载邮箱列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadConfigs();
}, [loadConfigs]);
const openAdd = () => {
setEditingId(null);
setForm({
host: "",
port: "993",
user: "",
password: "",
mailbox: "INBOX",
active: true,
});
setDialogOpen(true);
};
const openEdit = (c: EmailConfigRead) => {
setEditingId(c.id);
setFolders(null);
setForm({
host: c.host,
port: String(c.port),
user: c.user,
password: "",
mailbox: c.mailbox || "INBOX",
active: c.active,
});
setDialogOpen(true);
};
const loadFolders = async () => {
if (!editingId) return;
setFoldersLoading(true);
try {
const res = await emailConfigsApi.listFolders(editingId);
setFolders(res.folders);
if (res.folders.length > 0 && !form.mailbox) {
const inbox = res.folders.find((f) => f.decoded === "INBOX" || f.decoded === "收件箱");
if (inbox) setForm((f) => ({ ...f, mailbox: inbox.decoded }));
}
toast.success(`已加载 ${res.folders.length} 个邮箱夹`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "获取邮箱列表失败");
} finally {
setFoldersLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
if (editingId) {
const payload: EmailConfigUpdate = {
host: form.host,
port: parseInt(form.port, 10) || 993,
user: form.user,
mailbox: form.mailbox,
active: form.active,
};
if (form.password) payload.password = form.password;
await emailConfigsApi.update(editingId, payload);
toast.success("已更新");
} else {
await emailConfigsApi.create({
host: form.host,
port: parseInt(form.port, 10) || 993,
user: form.user,
password: form.password,
mailbox: form.mailbox,
active: form.active,
});
toast.success("已添加");
}
setDialogOpen(false);
await loadConfigs();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定删除该邮箱账户?")) return;
try {
await emailConfigsApi.delete(id);
toast.success("已删除");
await loadConfigs();
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
return (
<div className="p-6 max-w-4xl">
<div className="mb-4">
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
</a>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : configs.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
使使 IMAP_*
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Host</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Mailbox</TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.host}</TableCell>
<TableCell>{c.port}</TableCell>
<TableCell>{c.user}</TableCell>
<TableCell className="text-muted-foreground">{c.mailbox}</TableCell>
<TableCell>
{c.active ? (
<span className="text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded">
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(c.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader className="py-3">
<CardTitle className="text-sm"> 163 </CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
<p><strong>IMAP </strong> imap.163.com 993SSLIMAP/SMTP </p>
<p><strong></strong> 使 POP3/SMTP/IMAP </p>
<p><strong></strong> INBOX </p>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "编辑邮箱账户" : "添加邮箱账户"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label>IMAP Host</Label>
<Input
value={form.host}
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
placeholder="imap.163.com"
required
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
value={form.port}
onChange={(e) => setForm((f) => ({ ...f, port: e.target.value }))}
placeholder="993"
/>
</div>
<div className="grid gap-2">
<Label> / </Label>
<Input
type="email"
value={form.user}
onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))}
placeholder="user@example.com"
required
/>
</div>
<div className="grid gap-2">
<Label> / {editingId && "(留空则不修改)"}</Label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
placeholder={editingId ? "••••••••" : "请输入"}
autoComplete="off"
required={!editingId}
/>
</div>
<div className="grid gap-2">
<Label> / (Mailbox)</Label>
{editingId && (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={loadFolders}
disabled={foldersLoading}
>
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
)}
{folders && folders.length > 0 ? (
<Select
value={form.mailbox}
onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}
>
<SelectTrigger>
<SelectValue placeholder="选择邮箱夹" />
</SelectTrigger>
<SelectContent>
{folders.map((f) => (
<SelectItem key={f.raw} value={f.decoded}>
{f.decoded}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={form.mailbox}
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
placeholder="INBOX、收件箱或自定义标签163 等若 INBOX 失败会自动尝试收件箱)"
/>
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="active"
checked={form.active}
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
className="rounded border-input"
/>
<Label htmlFor="active"></Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span className="ml-2">{editingId ? "保存" : "添加"}</span>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
export default function SettingsPage() {
return (
<div className="p-6 max-w-2xl">
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
<div className="mt-6 flex flex-col gap-2">
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/templates" className="flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4" />
Excel / Word
</Link>
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/ai" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
AI
</Link>
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/email" className="flex items-center gap-2">
<Mail className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/cloud-docs" className="flex items-center gap-2">
<FileStack className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/cloud-doc-config" className="flex items-center gap-2">
<FileStack className="h-4 w-4" />
/ / API
</Link>
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/settings/portal-links" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
portalLinksApi,
type PortalLinkRead,
type PortalLinkCreate,
} from "@/lib/api/client";
import { Globe, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
import { toast } from "sonner";
export default function SettingsPortalLinksPage() {
const [links, setLinks] = useState<PortalLinkRead[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: "", url: "" });
const loadLinks = useCallback(async () => {
try {
const list = await portalLinksApi.list();
setLinks(list);
} catch {
toast.error("加载快捷门户列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadLinks();
}, [loadLinks]);
const openAdd = () => {
setEditingId(null);
setForm({ name: "", url: "" });
setDialogOpen(true);
};
const openEdit = (item: PortalLinkRead) => {
setEditingId(item.id);
setForm({ name: item.name, url: item.url });
setDialogOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) {
toast.error("请填写名称和链接");
return;
}
setSaving(true);
try {
if (editingId) {
await portalLinksApi.update(editingId, {
name: form.name.trim(),
url: form.url.trim(),
});
toast.success("已更新");
} else {
const payload: PortalLinkCreate = {
name: form.name.trim(),
url: form.url.trim(),
};
await portalLinksApi.create(payload);
toast.success("已添加");
}
setDialogOpen(false);
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("portal-links-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("确定删除该快捷门户入口?")) return;
try {
await portalLinksApi.delete(id);
toast.success("已删除");
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("portal-links-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
return (
<div className="p-6 max-w-4xl">
<div className="mb-4">
<Link
href="/settings"
className="text-sm text-muted-foreground hover:text-foreground"
>
</Link>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
<span className="ml-2"></span>
</Button>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : links.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{links.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline truncate max-w-[280px] inline-block"
>
{item.url}
</a>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item)}
title="编辑"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="删除"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button variant="ghost" size="sm" asChild title="打开">
<a href={item.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="portal-name"></Label>
<Input
id="portal-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="如:电子税务局、公积金"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="portal-url"></Label>
<Input
id="portal-url"
type="url"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://..."
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
{editingId ? "保存" : "添加"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
import { toast } from "sonner";
export default function SettingsTemplatesPage() {
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const loadTemplates = useCallback(async () => {
try {
const list = await templatesApi.list();
setTemplates(list);
} catch {
toast.error("加载模板列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
const handleFile = async (file: File) => {
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
if (![".xlsx", ".xltx", ".docx", ".dotx"].includes(ext)) {
toast.error("仅支持 .xlsx、.xltx、.docx、.dotx 文件");
return;
}
setUploading(true);
try {
await templatesApi.upload(file);
toast.success(`已上传:${file.name}`);
await loadTemplates();
} catch (e) {
toast.error(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const onDragLeave = () => setDragOver(false);
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = "";
};
const formatDate = (ts: number) =>
new Date(ts * 1000).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="p-6 max-w-4xl">
<div className="mb-4">
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
</a>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<p className="text-sm text-muted-foreground">
Excel.xlsx / .xltx Word.docx / .dotx/使
</p>
</CardHeader>
<CardContent className="space-y-6">
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25"
}`}
>
<input
type="file"
accept=".xlsx,.xltx,.docx,.dotx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.spreadsheetml.template,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.wordprocessingml.template"
onChange={onSelectFile}
className="hidden"
id="template-upload"
/>
<label htmlFor="template-upload" className="cursor-pointer block">
<Upload className="h-10 w-10 mx-auto text-muted-foreground" />
<p className="mt-2 text-sm font-medium"></p>
<p className="text-xs text-muted-foreground mt-1"> .xlsx.xltx.docx.dotx</p>
</label>
{uploading && (
<p className="mt-2 text-sm text-muted-foreground flex items-center justify-center gap-1">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
)}
</div>
<div>
<h3 className="text-sm font-medium mb-2"></h3>
{loading ? (
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin" />
</p>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.name}>
<TableCell>
{t.type === "excel" ? (
<FileSpreadsheet className="h-4 w-4 text-green-600" />
) : (
<FileText className="h-4 w-4 text-blue-600" />
)}
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-muted-foreground">
{(t.size / 1024).toFixed(1)} KB
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(t.uploaded_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
@@ -12,16 +13,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import ReactMarkdown from "react-markdown";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2 } from "lucide-react";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2, Plus, Search, CloudUpload } from "lucide-react";
import { toast } from "sonner";
import {
customersApi,
projectsApi,
templatesApi,
pushProjectToCloud,
downloadFile,
downloadFileAsBlob,
type CustomerRead,
type QuoteGenerateResponse,
type TemplateInfo,
} from "@/lib/api/client";
export default function WorkspacePage() {
@@ -34,10 +46,19 @@ export default function WorkspacePage() {
const [analyzing, setAnalyzing] = useState(false);
const [saving, setSaving] = useState(false);
const [generatingQuote, setGeneratingQuote] = useState(false);
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
const [newCustomerName, setNewCustomerName] = useState("");
const [newCustomerContact, setNewCustomerContact] = useState("");
const [newCustomerTags, setNewCustomerTags] = useState("");
const [addingCustomer, setAddingCustomer] = useState(false);
const [quoteTemplates, setQuoteTemplates] = useState<TemplateInfo[]>([]);
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
const [customerSearch, setCustomerSearch] = useState("");
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
const loadCustomers = useCallback(async () => {
const loadCustomers = useCallback(async (search?: string) => {
try {
const list = await customersApi.list();
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
setCustomers(list);
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
} catch (e) {
@@ -46,8 +67,50 @@ export default function WorkspacePage() {
}, [customerId]);
useEffect(() => {
loadCustomers();
}, [loadCustomers]);
loadCustomers(customerSearch);
}, [loadCustomers, customerSearch]);
const loadQuoteTemplates = useCallback(async () => {
try {
const list = await templatesApi.list();
setQuoteTemplates(list.filter((t) => t.type === "excel"));
} catch {
// ignore
}
}, []);
useEffect(() => {
loadQuoteTemplates();
}, [loadQuoteTemplates]);
const handleAddCustomer = async (e: React.FormEvent) => {
e.preventDefault();
const name = newCustomerName.trim();
if (!name) {
toast.error("请填写客户名称");
return;
}
setAddingCustomer(true);
try {
const created = await customersApi.create({
name,
contact_info: newCustomerContact.trim() || null,
tags: newCustomerTags.trim() || null,
});
toast.success("客户已添加");
setAddCustomerOpen(false);
setNewCustomerName("");
setNewCustomerContact("");
setNewCustomerTags("");
await loadCustomers(customerSearch);
setCustomerId(String(created.id));
} catch (err) {
const msg = err instanceof Error ? err.message : "添加失败";
toast.error(msg);
} finally {
setAddingCustomer(false);
}
};
const handleAnalyze = async () => {
if (!customerId || !rawText.trim()) {
@@ -72,6 +135,34 @@ export default function WorkspacePage() {
}
};
const handlePushToCloud = async (platform: "feishu" | "yuque" | "tencent") => {
if (projectId == null) return;
const md = solutionMd?.trim() || "";
if (!md) {
toast.error("请先在编辑器中填写方案内容后再推送");
return;
}
setPushToCloudLoading(true);
try {
const res = await pushProjectToCloud(projectId, {
platform,
body_md: md,
});
toast.success("已推送到云文档", {
action: res.url
? {
label: "打开链接",
onClick: () => window.open(res.url, "_blank"),
}
: undefined,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "推送失败");
} finally {
setPushToCloudLoading(false);
}
};
const handleSaveToArchive = async () => {
if (projectId == null) {
toast.error("请先进行 AI 解析");
@@ -96,7 +187,10 @@ export default function WorkspacePage() {
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
const res = await projectsApi.generateQuote(
projectId,
selectedQuoteTemplate || undefined
);
setLastQuote(res);
toast.success("报价单已生成");
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
@@ -120,7 +214,10 @@ export default function WorkspacePage() {
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
const res = await projectsApi.generateQuote(
projectId,
selectedQuoteTemplate || undefined
);
setLastQuote(res);
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
@@ -158,18 +255,97 @@ export default function WorkspacePage() {
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
<div className="space-y-1.5">
<Label></Label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger>
<SelectValue placeholder="选择客户" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1 items-center">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索客户名称/联系方式"
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="pl-8 h-9"
/>
</div>
</div>
<div className="flex gap-1">
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择客户" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-2">
{c.name}
{c.tags && (
<span className="text-muted-foreground text-xs">
({c.tags})
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Dialog open={addCustomerOpen} onOpenChange={setAddCustomerOpen}>
<DialogTrigger asChild>
<Button type="button" size="icon" variant="outline" title="新建客户">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleAddCustomer}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="customer-name"></Label>
<Input
id="customer-name"
value={newCustomerName}
onChange={(e) => setNewCustomerName(e.target.value)}
placeholder="必填"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-contact"></Label>
<Input
id="customer-contact"
value={newCustomerContact}
onChange={(e) => setNewCustomerContact(e.target.value)}
placeholder="选填"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="customer-tags"></Label>
<Input
id="customer-tags"
value={newCustomerTags}
onChange={(e) => setNewCustomerTags(e.target.value)}
placeholder="如:重点客户, 已签约"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddCustomerOpen(false)}
>
</Button>
<Button type="submit" disabled={addingCustomer}>
{addingCustomer ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"添加"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0">
<Label>/</Label>
@@ -220,7 +396,26 @@ export default function WorkspacePage() {
</div>
{/* Floating Action Bar */}
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)]">
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)] flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<Select
value={selectedQuoteTemplate || "__latest__"}
onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}
>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="使用最新上传" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__latest__">使</SelectItem>
{quoteTemplates.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
@@ -260,6 +455,30 @@ export default function WorkspacePage() {
)}
<span className="ml-1.5"> PDF</span>
</Button>
<Select
value="__none__"
onValueChange={(v) => {
if (v === "feishu" || v === "yuque" || v === "tencent") handlePushToCloud(v);
}}
disabled={pushToCloudLoading || projectId == null}
>
<SelectTrigger className="w-[140px] h-8">
{pushToCloudLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CloudUpload className="h-4 w-4 mr-1" />
)}
<SelectValue placeholder="推送到云文档" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" disabled>
</SelectItem>
<SelectItem value="feishu"></SelectItem>
<SelectItem value="yuque"></SelectItem>
<SelectItem value="tencent"></SelectItem>
</SelectContent>
</Select>
{projectId != null && (
<span className="text-xs text-muted-foreground ml-2">
#{projectId}

View File

@@ -2,27 +2,63 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import {
FileText,
FolderArchive,
Settings,
Building2,
Globe,
PiggyBank,
FileStack,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { HistoricalReferences } from "@/components/historical-references";
const QUICK_LINKS = [
{ label: "国家税务总局门户", href: "https://www.chinatax.gov.cn", icon: Building2 },
{ label: "电子税务局", href: "https://etax.chinatax.gov.cn", icon: Globe },
{ label: "公积金管理中心", href: "https://www.12329.com.cn", icon: PiggyBank },
];
import { cloudDocsApi, portalLinksApi, type CloudDocLinkRead, type PortalLinkRead } from "@/lib/api/client";
export function AppSidebar() {
const pathname = usePathname();
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
const loadCloudDocs = useCallback(async () => {
try {
const list = await cloudDocsApi.list();
setCloudDocs(list);
} catch {
setCloudDocs([]);
}
}, []);
useEffect(() => {
loadCloudDocs();
}, [loadCloudDocs]);
useEffect(() => {
const onCloudDocsChanged = () => loadCloudDocs();
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
}, [loadCloudDocs]);
const loadPortalLinks = useCallback(async () => {
try {
const list = await portalLinksApi.list();
setPortalLinks(list);
} catch {
setPortalLinks([]);
}
}, []);
useEffect(() => {
loadPortalLinks();
}, [loadPortalLinks]);
useEffect(() => {
const onPortalLinksChanged = () => loadPortalLinks();
window.addEventListener("portal-links-changed", onPortalLinksChanged);
return () => window.removeEventListener("portal-links-changed", onPortalLinksChanged);
}, [loadPortalLinks]);
const nav = [
{ href: "/workspace", label: "需求与方案", icon: FileText },
@@ -54,25 +90,70 @@ export function AppSidebar() {
))}
</nav>
<Separator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2">
</p>
<div className="flex flex-col gap-1">
{QUICK_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="p-2 shrink-0">
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/cloud-docs"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理云文档入口"
>
<link.icon className="h-4 w-4 shrink-0" />
{link.label}
</a>
))}
<Settings2 className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex flex-col gap-1">
{cloudDocs.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
cloudDocs.map((doc) => (
<a
key={doc.id}
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<FileStack className="h-4 w-4 shrink-0" />
<span className="truncate">{doc.name}</span>
</a>
))
)}
</div>
</div>
<div className="p-2 shrink-0">
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/portal-links"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理快捷门户"
>
<Settings2 className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex flex-col gap-1">
{portalLinks.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
portalLinks.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Globe className="h-4 w-4 shrink-0" />
<span className="truncate">{link.name}</span>
</a>
))
)}
</div>
</div>
</div>
{pathname === "/workspace" && <HistoricalReferences />}

View File

@@ -1,22 +1,59 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { listProjects, type ProjectRead } from "@/lib/api/client";
import { Copy, Search, FileText } from "lucide-react";
import {
listProjects,
projectsApi,
type ProjectRead,
} from "@/lib/api/client";
import { Copy, Search, FileText, Eye, Pencil, Loader2 } from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
function parseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr?.trim()) return [];
return tagsStr
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
export function HistoricalReferences() {
const [projects, setProjects] = useState<ProjectRead[]>([]);
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string>("");
const [loading, setLoading] = useState(true);
const [previewProject, setPreviewProject] = useState<ProjectRead | null>(null);
const [editProject, setEditProject] = useState<ProjectRead | null>(null);
const [editRaw, setEditRaw] = useState("");
const [editMd, setEditMd] = useState("");
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await listProjects();
const data = await listProjects(
selectedTag ? { customer_tag: selectedTag } : undefined
);
setProjects(data);
} catch (e) {
toast.error("加载历史项目失败");
@@ -24,19 +61,32 @@ export function HistoricalReferences() {
} finally {
setLoading(false);
}
}, []);
}, [selectedTag]);
useEffect(() => {
load();
}, [load]);
const filtered = search.trim()
? projects.filter(
const allTags = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) =>
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
);
return Array.from(set).sort();
}, [projects]);
const filtered = useMemo(() => {
let list = projects;
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(p) =>
p.raw_requirement.toLowerCase().includes(search.toLowerCase()) ||
(p.ai_solution_md || "").toLowerCase().includes(search.toLowerCase())
)
: projects.slice(0, 10);
p.raw_requirement.toLowerCase().includes(q) ||
(p.ai_solution_md || "").toLowerCase().includes(q)
);
}
return list.slice(0, 20);
}, [projects, search]);
const copySnippet = (text: string, label: string) => {
if (!text) return;
@@ -44,20 +94,67 @@ export function HistoricalReferences() {
toast.success(`已复制 ${label}`);
};
const openPreview = (p: ProjectRead) => setPreviewProject(p);
const openEdit = (p: ProjectRead) => {
setEditProject(p);
setEditRaw(p.raw_requirement);
setEditMd(p.ai_solution_md || "");
};
const handleSaveEdit = async () => {
if (!editProject) return;
setSaving(true);
try {
await projectsApi.update(editProject.id, {
raw_requirement: editRaw,
ai_solution_md: editMd || null,
});
toast.success("已保存");
setEditProject(null);
load();
} catch (e) {
toast.error("保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="border-t p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
</p>
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
<div className="space-y-2 mb-2">
<div className="flex gap-1 items-center">
<Select
value={selectedTag || "__all__"}
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="按标签收纳" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__" className="text-xs">
</SelectItem>
{allTags.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{loading ? (
@@ -72,20 +169,43 @@ export function HistoricalReferences() {
"rounded border bg-background/50 p-2 text-xs space-y-1"
)}
>
<p className="font-medium text-foreground line-clamp-1">
#{p.id}
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
<span> #{p.id}</span>
{p.customer?.tags && (
<span className="text-muted-foreground font-normal truncate max-w-[60%]">
{parseTags(p.customer.tags).slice(0, 2).join(", ")}
</span>
)}
</p>
<p className="text-muted-foreground line-clamp-2">
{p.raw_requirement.slice(0, 80)}
</p>
<div className="flex gap-1 pt-1">
<div className="flex flex-wrap gap-1 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.raw_requirement, "原始需求")
}
onClick={() => openPreview(p)}
title="预览"
>
<Eye className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => openEdit(p)}
title="编辑"
>
<Pencil className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() => copySnippet(p.raw_requirement, "原始需求")}
>
<Copy className="h-3 w-3 mr-0.5" />
@@ -94,9 +214,7 @@ export function HistoricalReferences() {
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.ai_solution_md || "", "方案")
}
onClick={() => copySnippet(p.ai_solution_md || "", "方案")}
>
<Copy className="h-3 w-3 mr-0.5" />
@@ -106,6 +224,80 @@ export function HistoricalReferences() {
))
)}
</div>
{/* 预览弹窗 */}
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
#{previewProject?.id}
{previewProject?.customer?.name && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{previewProject.customer.name}
</span>
)}
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="requirement" className="flex-1 min-h-0 flex flex-col overflow-hidden">
<TabsList>
<TabsTrigger value="requirement"></TabsTrigger>
<TabsTrigger value="solution"></TabsTrigger>
</TabsList>
<TabsContent value="requirement" className="mt-2 overflow-auto flex-1 min-h-0">
<pre className="text-xs whitespace-pre-wrap bg-muted/50 p-3 rounded-md">
{previewProject?.raw_requirement ?? ""}
</pre>
</TabsContent>
<TabsContent value="solution" className="mt-2 overflow-auto flex-1 min-h-0">
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
{previewProject?.ai_solution_md ? (
<ReactMarkdown>{previewProject.ai_solution_md}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* 二次编辑弹窗 */}
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle> #{editProject?.id}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 flex-1 min-h-0 overflow-auto">
<div className="grid gap-2">
<Label></Label>
<textarea
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editRaw}
onChange={(e) => setEditRaw(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label> (Markdown)</Label>
<textarea
className="min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none"
value={editMd}
onChange={(e) => setEditMd(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditProject(null)} disabled={saving}>
</Button>
<Button onClick={handleSaveEdit} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };

View File

@@ -18,17 +18,20 @@ export interface CustomerRead {
id: number;
name: string;
contact_info: string | null;
tags: string | null;
created_at: string;
}
export interface CustomerCreate {
name: string;
contact_info?: string | null;
tags?: string | null;
}
export interface CustomerUpdate {
name?: string;
contact_info?: string | null;
tags?: string | null;
}
export interface RequirementAnalyzeRequest {
@@ -69,7 +72,68 @@ export interface FinanceSyncResult {
}
export interface FinanceSyncResponse {
items: FinanceSyncResult[];
status: string;
new_files: number;
details: FinanceSyncResult[];
}
export interface FinanceRecordRead {
id: number;
month: string;
type: string;
file_name: string;
file_path: string;
amount: number | null;
billing_date: string | null;
created_at: string;
}
export interface TemplateInfo {
name: string;
type: "excel" | "word";
size: number;
uploaded_at: number;
}
export interface AIConfig {
id?: string;
name?: string;
provider: string;
api_key: string;
base_url: string;
model_name: string;
temperature: number;
system_prompt_override: string;
}
export interface AIConfigListItem {
id: string;
name: string;
provider: string;
model_name: string;
base_url: string;
api_key_configured: boolean;
is_active: boolean;
}
export interface AIConfigCreate {
name?: string;
provider?: string;
api_key?: string;
base_url?: string;
model_name?: string;
temperature?: number;
system_prompt_override?: string;
}
export interface AIConfigUpdate {
name?: string;
provider?: string;
api_key?: string;
base_url?: string;
model_name?: string;
temperature?: number;
system_prompt_override?: string;
}
export interface ProjectRead {
@@ -84,6 +148,7 @@ export interface ProjectRead {
}
export interface ProjectUpdate {
raw_requirement?: string | null;
ai_solution_md?: string | null;
status?: string;
}
@@ -112,10 +177,15 @@ async function request<T>(
if (!res.ok) {
const text = await res.text();
let detail = text;
let detail: string = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
const raw = j.detail ?? text;
if (Array.isArray(raw) && raw.length > 0) {
detail = raw.map((x: { msg?: string }) => x.msg ?? JSON.stringify(x)).join("; ");
} else {
detail = typeof raw === "string" ? raw : JSON.stringify(raw);
}
} catch {
// keep text
}
@@ -176,7 +246,10 @@ export async function downloadFileAsBlob(
// --------------- Customers ---------------
export const customersApi = {
list: () => request<CustomerRead[]>("/customers/"),
list: (params?: { q?: string }) =>
request<CustomerRead[]>(
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/"
),
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
create: (body: CustomerCreate) =>
request<CustomerRead>("/customers/", {
@@ -207,10 +280,11 @@ export const projectsApi = {
body: JSON.stringify(body),
}),
generateQuote: (projectId: number) =>
request<QuoteGenerateResponse>(`/projects/${projectId}/generate_quote`, {
method: "POST",
}),
generateQuote: (projectId: number, template?: string | null) =>
request<QuoteGenerateResponse>(
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
{ method: "POST" }
),
generateContract: (projectId: number, body: ContractGenerateRequest) =>
request<ContractGenerateResponse>(
@@ -222,20 +296,285 @@ export const projectsApi = {
),
};
export async function listProjects(): Promise<ProjectRead[]> {
return request<ProjectRead[]>("/projects/");
export async function listProjects(params?: {
customer_tag?: string;
}): Promise<ProjectRead[]> {
const searchParams: Record<string, string> = {};
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
return request<ProjectRead[]>("/projects/", {
params: searchParams,
});
}
export async function getProject(projectId: number): Promise<ProjectRead> {
return request<ProjectRead>(`/projects/${projectId}`);
}
// --------------- Settings / Templates ---------------
export const templatesApi = {
list: () => request<TemplateInfo[]>("/settings/templates"),
upload: async (file: File): Promise<{ name: string; path: string }> => {
const base = apiBase();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${base}/settings/templates/upload`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
} catch {
// keep text
}
throw new Error(detail);
}
return res.json();
},
};
// --------------- AI Settings ---------------
export const aiSettingsApi = {
get: () => request<AIConfig>("/settings/ai"),
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
create: (body: AIConfigCreate) =>
request<AIConfig>("/settings/ai", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: AIConfigUpdate) =>
request<AIConfig>(`/settings/ai/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
activate: (id: string) =>
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
test: (configId?: string) =>
request<{ status: string; message: string }>(
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
{ method: "POST" }
),
};
// --------------- Email Configs (multi-account sync) ---------------
export interface EmailConfigRead {
id: string;
host: string;
port: number;
user: string;
mailbox: string;
active: boolean;
}
export interface EmailConfigCreate {
host: string;
port?: number;
user: string;
password: string;
mailbox?: string;
active?: boolean;
}
export interface EmailConfigUpdate {
host?: string;
port?: number;
user?: string;
password?: string;
mailbox?: string;
active?: boolean;
}
export interface EmailFolder {
raw: string;
decoded: string;
}
export const emailConfigsApi = {
list: () => request<EmailConfigRead[]>("/settings/email"),
create: (body: EmailConfigCreate) =>
request<EmailConfigRead>("/settings/email", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: EmailConfigUpdate) =>
request<EmailConfigRead>(`/settings/email/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/email/${id}`, { method: "DELETE" }),
/** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */
listFolders: (configId: string) =>
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
};
// --------------- Cloud Docs (快捷入口) ---------------
export interface CloudDocLinkRead {
id: string;
name: string;
url: string;
}
export interface CloudDocLinkCreate {
name: string;
url: string;
}
export interface CloudDocLinkUpdate {
name?: string;
url?: string;
}
export const cloudDocsApi = {
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
create: (body: CloudDocLinkCreate) =>
request<CloudDocLinkRead>("/settings/cloud-docs", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: CloudDocLinkUpdate) =>
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
};
// --------------- Cloud Doc Config (API 凭证) ---------------
export interface CloudDocConfigRead {
feishu: { app_id: string; app_secret_configured: boolean };
yuque: { token_configured: boolean; default_repo: string };
tencent: { client_id: string; client_secret_configured: boolean };
}
export interface CloudDocConfigUpdate {
feishu?: { app_id?: string; app_secret?: string };
yuque?: { token?: string; default_repo?: string };
tencent?: { client_id?: string; client_secret?: string };
}
export const cloudDocConfigApi = {
get: () => request<CloudDocConfigRead>("/settings/cloud-doc-config"),
update: (body: CloudDocConfigUpdate) =>
request<CloudDocConfigRead>("/settings/cloud-doc-config", {
method: "PUT",
body: JSON.stringify(body),
}),
};
// --------------- Push to Cloud ---------------
export interface PushToCloudRequest {
platform: "feishu" | "yuque" | "tencent";
title?: string;
body_md?: string;
}
export interface PushToCloudResponse {
url: string;
cloud_doc_id: string;
}
export function pushProjectToCloud(
projectId: number,
body: PushToCloudRequest
): Promise<PushToCloudResponse> {
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
method: "POST",
body: JSON.stringify(body),
});
}
// --------------- Portal Links (快捷门户) ---------------
export interface PortalLinkRead {
id: string;
name: string;
url: string;
}
export interface PortalLinkCreate {
name: string;
url: string;
}
export interface PortalLinkUpdate {
name?: string;
url?: string;
}
export const portalLinksApi = {
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
create: (body: PortalLinkCreate) =>
request<PortalLinkRead>("/settings/portal-links", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: PortalLinkUpdate) =>
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
};
// --------------- Finance ---------------
export const financeApi = {
sync: () =>
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
/** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
const base = apiBase();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${base}/finance/upload`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
} catch {
// keep text
}
throw new Error(detail);
}
return res.json();
},
/** Update amount/billing_date of a record (e.g. after manual review). */
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
request<FinanceRecordRead>(`/finance/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/** List distinct months with records (YYYY-MM). */
listMonths: () => request<string[]>("/finance/months"),
/** List records for a month (YYYY-MM). */
listRecords: (month: string) =>
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
/** Returns the URL to download the zip (or use downloadFile with the path). */
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,

View File

@@ -0,0 +1,22 @@
/**
* 快捷门户地址配置
* 通过环境变量 NEXT_PUBLIC_* 覆盖,未设置时使用默认值
*/
const DEFAULTS = {
/** 国家税务总局门户 */
TAX_GATEWAY_URL: "https://www.chinatax.gov.cn",
/** 电子税务局(如上海电子税务局) */
TAX_PORTAL_URL: "https://etax.shanghai.chinatax.gov.cn:8443/",
/** 公积金管理中心 */
HOUSING_FUND_PORTAL_URL: "https://www.shzfgjj.cn/static/unit/web/",
} as const;
export const portalConfig = {
taxGatewayUrl:
process.env.NEXT_PUBLIC_TAX_GATEWAY_URL ?? DEFAULTS.TAX_GATEWAY_URL,
taxPortalUrl:
process.env.NEXT_PUBLIC_TAX_PORTAL_URL ?? DEFAULTS.TAX_PORTAL_URL,
housingFundPortalUrl:
process.env.NEXT_PUBLIC_HOUSING_FUND_PORTAL_URL ??
DEFAULTS.HOUSING_FUND_PORTAL_URL,
} as const;

7626
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,28 +9,29 @@
"lint": "next lint"
},
"dependencies": {
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"tailwind-merge": "^2.5.4",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"sonner": "^1.5.0"
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -39,8 +40,7 @@
"eslint-config-next": "14.2.18",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/typography": "^0.5.15"
"typescript": "^5.6.3"
}
}

231
local.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
本地邮箱同步测试脚本(不依赖项目代码)
- 支持多个 IMAP 账户
- 每个账户单独测试登录、选择邮箱夹、列出未读邮件主题
- 结束时打印哪些账户成功、哪些失败(以及失败原因)
用来在对接「财务归档 → 同步」之前,本地先把邮箱配置调通。
"""
import imaplib
import email
from email.header import decode_header
from dataclasses import dataclass
from typing import List, Tuple, Optional
# ===== 1. 在这里配置你的邮箱账户 =====
# 163 示例IMAP 服务器 imap.163.com端口 993密码为「IMAP/SMTP 授权码」
ACCOUNTS = [
{
"name": "163 财务邮箱",
"host": "imap.163.com",
"port": 993,
"user": "danielghost@163.com",
"password": "TZjkMANWyYEDXui7",
"mailbox": "INBOX",
},
]
# ===== 2. 工具函数 =====
def _decode_header_value(value: Optional[str]) -> str:
if not value:
return ""
parts = decode_header(value)
decoded = ""
for text, enc in parts:
if isinstance(text, bytes):
decoded += text.decode(enc or "utf-8", errors="ignore")
else:
decoded += text
return decoded
def _list_mailboxes(imap: imaplib.IMAP4_SSL) -> List[Tuple[str, str]]:
"""列出所有邮箱夹,返回 [(raw_name, decoded_name)]"""
status, data = imap.list()
if status != "OK" or not data:
return []
result: List[Tuple[str, str]] = []
for line in data:
if not isinstance(line, bytes):
continue
try:
line_str = line.decode("ascii", errors="replace")
except Exception:
continue
# 典型格式b'(\\HasNoChildren) "/" "&UXZO1mWHTvZZOQ-"'
parts = line_str.split(" ")
if not parts:
continue
raw = parts[-1].strip('"')
# 简单处理 UTF-7足够看中文「收件箱」
try:
decoded = raw.encode("latin1").decode("utf-7")
except Exception:
decoded = raw
result.append((raw, decoded))
return result
def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
"""
尝试选中邮箱夹:
1. 直接 SELECT 配置的名字 / INBOX读写、只读
2. 尝试常见 UTF-7 编码收件箱(如 &XfJT0ZTx-
3. 遍历 LIST寻找带 \\Inbox 标记或名称包含 INBOX/收件箱 的文件夹,再 SELECT 实际名称
"""
import re
name = (mailbox or "INBOX").strip() or "INBOX"
# 1) 优先尝试配置名和标准 INBOX
primary_candidates = []
if name not in primary_candidates:
primary_candidates.append(name)
if "INBOX" not in primary_candidates:
primary_candidates.append("INBOX")
for candidate in primary_candidates:
for readonly in (False, True):
print(f" - 尝试 SELECT '{candidate}' (readonly={readonly}) ...")
try:
status, _ = imap.select(candidate, readonly=readonly)
if status == "OK":
print(" ✓ 直接 SELECT 成功")
return True
except Exception as e:
print(f" ⚠ 直接 SELECT 失败: {e}")
# 2) 尝试常见 UTF-7 编码收件箱
for candidate in ["&XfJT0ZTx-"]:
for readonly in (False, True):
print(f" - 尝试 UTF-7 收件箱 '{candidate}' (readonly={readonly}) ...")
try:
status, _ = imap.select(candidate, readonly=readonly)
if status == "OK":
print(" ✓ UTF-7 收件箱 SELECT 成功")
return True
except Exception as e:
print(f" ⚠ SELECT '{candidate}' 失败: {e}")
# 3) 通过 LIST 结果匹配带 \\Inbox 或名称包含 INBOX/收件箱 的文件夹
print(" - 尝试通过 LIST 匹配文件夹 ...")
try:
status, data = imap.list()
if status != "OK" or not data:
print(" ⚠ LIST 返回为空或非 OK")
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
return False
except Exception as e:
print(f" ⚠ LIST 失败: {e}")
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
return False
for line in data:
if isinstance(line, bytes):
line_str = line.decode("utf-8", errors="ignore")
else:
line_str = line
if "\\Inbox" not in line_str and all(
kw not in line_str for kw in ['"INBOX"', '"Inbox"', '"收件箱"']
):
continue
m = re.search(r'"([^"]+)"\s*$', line_str)
if not m:
continue
actual_name = m.group(1)
print(f" 尝试 SELECT 列表中的 '{actual_name}' ...")
for readonly in (False, True):
try:
status2, _ = imap.select(actual_name, readonly=readonly)
if status2 == "OK":
print(" ✓ 通过 LIST 匹配成功")
return True
except Exception as e:
print(f" ⚠ SELECT '{actual_name}' (readonly={readonly}) 失败: {e}")
print(" ✗ 无法选择任何收件箱,请检查 mailbox 名称或在 UI 里用「加载文件夹」重选")
return False
@dataclass
class SyncResult:
name: str
user: str
ok: bool
error: Optional[str] = None
unread_count: int = 0
# ===== 3. 主逻辑 =====
def sync_account(conf: dict) -> SyncResult:
name = conf.get("name") or conf.get("user") or "未命名账户"
host = conf["host"]
port = int(conf.get("port", 993))
user = conf["user"]
password = conf["password"]
mailbox = conf.get("mailbox", "INBOX")
print(f"\n=== 开始同步账户:{name} ({user}) ===")
try:
with imaplib.IMAP4_SSL(host, port) as imap:
print(f" - 连接 {host}:{port} ...")
imap.login(user, password)
print(" ✓ 登录成功")
if not _select_mailbox(imap, mailbox):
return SyncResult(name=name, user=user, ok=False, error=f"无法选择邮箱夹 {mailbox}")
status, data = imap.search(None, "UNSEEN")
if status != "OK":
return SyncResult(name=name, user=user, ok=False, error="SEARCH UNSEEN 失败")
ids = data[0].split()
print(f" ✓ 未读邮件数量:{len(ids)}")
for msg_id in ids[:10]: # 只看前 10 封,避免刷屏
status, msg_data = imap.fetch(msg_id, "(RFC822)")
if status != "OK" or not msg_data:
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = _decode_header_value(msg.get("Subject"))
print(f" - 未读主题:{subject!r}")
return SyncResult(name=name, user=user, ok=True, unread_count=len(ids))
except Exception as e:
return SyncResult(name=name, user=user, ok=False, error=str(e))
def main():
results: List[SyncResult] = []
for conf in ACCOUNTS:
results.append(sync_account(conf))
print("\n=== 汇总 ===")
for r in results:
if r.ok:
print(f"{r.name} ({r.user}) 同步成功,未读 {r.unread_count}")
else:
print(f"{r.name} ({r.user}) 同步失败:{r.error}")
failed = [r for r in results if not r.ok]
if failed:
print("\n以下账户未同步成功,请根据错误信息调整配置或在系统 UI 里重新选择邮箱夹:")
for r in failed:
print(f" - {r.name} ({r.user}){r.error}")
else:
print("\n所有账户均同步成功。")
if __name__ == "__main__":
main()

View File

@@ -17,3 +17,4 @@ reportlab==4.2.5
python-docx==1.1.2
python-multipart==0.0.12
pymupdf==1.24.10