fix:优化数据
This commit is contained in:
@@ -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
901
backend.log
Normal 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]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
300
backend/app/routers/ai_settings.py
Normal file
300
backend/app/routers/ai_settings.py
Normal 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", ""),
|
||||
)
|
||||
139
backend/app/routers/cloud_doc_config.py
Normal file
139
backend/app/routers/cloud_doc_config.py
Normal 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)}
|
||||
91
backend/app/routers/cloud_docs.py
Normal file
91
backend/app/routers/cloud_docs.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
183
backend/app/routers/email_configs.py
Normal file
183
backend/app/routers/email_configs.py
Normal 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 []
|
||||
@@ -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}")
|
||||
|
||||
91
backend/app/routers/portal_links.py
Normal file
91
backend/app/routers/portal_links.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
93
backend/app/routers/settings.py
Normal file
93
backend/app/routers/settings.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
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": (
|
||||
"你是一名严谨的系统架构师,只能输出有效的 JSON,不要输出任何解释文字。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0.2,
|
||||
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)
|
||||
|
||||
315
backend/app/services/cloud_doc_service.py
Normal file
315
backend/app/services/cloud_doc_service.py
Normal 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}")
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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,16 +90,107 @@ 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]] = []
|
||||
|
||||
msg_id = msg.get("Message-ID") or ""
|
||||
subject = _decode_header_value(msg.get("Subject"))
|
||||
|
||||
SYNC_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(SYNC_DB_PATH)
|
||||
try:
|
||||
_ensure_sync_history_table(conn)
|
||||
|
||||
for part in msg.walk():
|
||||
content_disposition = part.get("Content-Disposition", "")
|
||||
@@ -92,10 +202,11 @@ def _save_attachment(
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
content_type = part.get_content_type()
|
||||
maintype = part.get_content_maintype()
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".xlsx"):
|
||||
continue
|
||||
|
||||
# Accept pdf and common images
|
||||
maintype = part.get_content_maintype()
|
||||
if maintype not in ("application", "image"):
|
||||
continue
|
||||
|
||||
@@ -103,54 +214,267 @@ def _save_attachment(
|
||||
if not data:
|
||||
continue
|
||||
|
||||
# 分类:基于主题 + 文件名
|
||||
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
|
||||
# Ensure unique filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
stem = file_path.stem
|
||||
suffix = file_path.suffix
|
||||
stem, suffix = file_path.stem, file_path.suffix
|
||||
file_path = base_dir / f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(data)
|
||||
file_path.write_bytes(data)
|
||||
|
||||
saved.append((filename, str(file_path)))
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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 _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")
|
||||
|
||||
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]):
|
||||
raise RuntimeError("IMAP_HOST, IMAP_USER, IMAP_PASSWORD must be set.")
|
||||
return
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
# Use strict TLS context for modern protocols (TLS 1.2+)
|
||||
tls_context = ssl.create_default_context()
|
||||
|
||||
with imaplib.IMAP4_SSL(host, port) as imap:
|
||||
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)
|
||||
imap.select(mailbox)
|
||||
# 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)"
|
||||
)
|
||||
|
||||
# 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")
|
||||
# 首次同步(历史库无记录):拉取全部邮件中的附件,由 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 results
|
||||
return
|
||||
|
||||
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":
|
||||
@@ -158,46 +482,83 @@ async def sync_finance_emails() -> List[Dict[str, Any]]:
|
||||
|
||||
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:
|
||||
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=file_name,
|
||||
file_path=file_path,
|
||||
file_name=final_name,
|
||||
file_path=final_path,
|
||||
amount=amount,
|
||||
billing_date=billing_date,
|
||||
)
|
||||
# NOTE: created_at defaults at DB layer
|
||||
db.add(record)
|
||||
db.flush()
|
||||
|
||||
results.append(
|
||||
{
|
||||
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")
|
||||
|
||||
|
||||
async def sync_finance_emails() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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]]:
|
||||
from backend.app.routers.email_configs import get_email_configs_for_sync
|
||||
|
||||
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()
|
||||
|
||||
if not results and errors:
|
||||
# 所有账户都失败了,整体报错,前端可显示详细原因。
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
return results
|
||||
|
||||
return await asyncio.to_thread(_sync)
|
||||
@@ -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)
|
||||
|
||||
|
||||
90
backend/app/services/invoice_upload.py
Normal file
90
backend/app/services/invoice_upload.py
Normal 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
42
docker-compose.dev.yml
Normal 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:
|
||||
@@ -1,3 +1,6 @@
|
||||
# 生产/默认:分层构建,仅代码变更时只重建最后一层。
|
||||
# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
# 或执行: ./docker_dev.sh dev
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
|
||||
@@ -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)"
|
||||
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
4
frontend/.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
# 国内镜像,加快安装、避免卡死(Docker 内已单独配置,此文件供本地/CI 使用)
|
||||
registry=https://registry.npmmirror.com
|
||||
fetch-retries=5
|
||||
fetch-timeout=60000
|
||||
@@ -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
|
||||
|
||||
503
frontend/app/(main)/finance/page.tsx
Normal file
503
frontend/app/(main)/finance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
frontend/app/(main)/settings/ai/page.tsx
Normal file
391
frontend/app/(main)/settings/ai/page.tsx
Normal 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 (0–2)</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>
|
||||
);
|
||||
}
|
||||
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal file
209
frontend/app/(main)/settings/cloud-doc-config/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal file
261
frontend/app/(main)/settings/cloud-docs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
364
frontend/app/(main)/settings/email/page.tsx
Normal file
364
frontend/app/(main)/settings/email/page.tsx
Normal 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,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/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>
|
||||
);
|
||||
}
|
||||
50
frontend/app/(main)/settings/page.tsx
Normal file
50
frontend/app/(main)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal file
256
frontend/app/(main)/settings/portal-links/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/app/(main)/settings/templates/page.tsx
Normal file
175
frontend/app/(main)/settings/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
<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}
|
||||
|
||||
@@ -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-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="管理云文档入口"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{QUICK_LINKS.map((link) => (
|
||||
{cloudDocs.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-2">暂无入口,去设置添加</p>
|
||||
) : (
|
||||
cloudDocs.map((doc) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<link.icon className="h-4 w-4 shrink-0" />
|
||||
{link.label}
|
||||
<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 />}
|
||||
|
||||
@@ -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,13 +94,59 @@ 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">
|
||||
<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="搜索项目..."
|
||||
@@ -59,6 +155,7 @@ export function HistoricalReferences() {
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{loading ? (
|
||||
<p className="text-xs text-muted-foreground px-2">加载中...</p>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
119
frontend/components/ui/dialog.tsx
Normal file
119
frontend/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
80
frontend/components/ui/table.tsx
Normal file
80
frontend/components/ui/table.tsx
Normal 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 };
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
22
frontend/lib/portal-config.ts
Normal file
22
frontend/lib/portal-config.ts
Normal 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
7626
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
231
local.py
Normal 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()
|
||||
@@ -17,3 +17,4 @@ reportlab==4.2.5
|
||||
python-docx==1.1.2
|
||||
|
||||
python-multipart==0.0.12
|
||||
pymupdf==1.24.10
|
||||
|
||||
Reference in New Issue
Block a user